diff --git a/README.md b/README.md index 5ed34c06a..003baf430 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ Read more: - [How to add Mermaid extension](https://github.com/gravity-ui/markdown-editor/blob/main/docs/how-to-connect-mermaid-extension.md) - [How to write extension](https://github.com/gravity-ui/markdown-editor/blob/main/docs/how-to-create-extension.md) - [How to add GPT extension](https://github.com/gravity-ui/markdown-editor/blob/main/docs/how-to-connect-gpt-extensions.md) +- [How to add text binding extension in markdown](https://github.com/gravity-ui/markdown-editor/blob/main/docs/how-to-add-text-binding-extension-in-markdown.md) + ### i18n diff --git a/demo/Playground.tsx b/demo/Playground.tsx index 30d227510..aa4626ca9 100644 --- a/demo/Playground.tsx +++ b/demo/Playground.tsx @@ -11,6 +11,7 @@ import { MarkupString, NumberInput, RenderPreview, + ToolbarGroupData, UseMarkdownEditorProps, logger, markupToolbarConfigs, @@ -18,12 +19,14 @@ import { wysiwygToolbarConfigs, } from '../src'; import type {EscapeConfig, ToolbarActionData} from '../src/bundle/Editor'; +import {Extension} from '../src/cm/state'; import {FoldingHeading} from '../src/extensions/yfm/FoldingHeading'; import {Math} from '../src/extensions/yfm/Math'; import {Mermaid} from '../src/extensions/yfm/Mermaid'; import {YfmHtmlBlock} from '../src/extensions/yfm/YfmHtmlBlock'; import {getSanitizeYfmHtmlBlock} from '../src/extensions/yfm/YfmHtmlBlock/utils'; import {cloneDeep} from '../src/lodash'; +import {CodeEditor} from '../src/markup/editor'; import type {FileUploadHandler} from '../src/utils/upload'; import {VERSION} from '../src/version'; @@ -76,8 +79,10 @@ export type PlaygroundProps = { initialSplitModeEnabled?: boolean; renderPreviewDefined?: boolean; height?: CSSProperties['height']; + markupConfigExtensions?: Extension[]; escapeConfig?: EscapeConfig; wysiwygCommandMenuConfig?: wysiwygToolbarConfigs.WToolbarItemData[]; + markupToolbarConfig?: ToolbarGroupData[]; onChangeEditorType?: (mode: MarkdownEditorMode) => void; onChangeSplitModeEnabled?: (splitModeEnabled: boolean) => void; } & Pick< @@ -123,6 +128,8 @@ export const Playground = React.memo((props) => { extensionOptions, wysiwygToolbarConfig, wysiwygCommandMenuConfig, + markupConfigExtensions, + markupToolbarConfig, escapeConfig, enableSubmitInPreview, hidePreviewAfterSubmit, @@ -171,6 +178,9 @@ export const Playground = React.memo((props) => { commandMenu: {actions: wysiwygCommandMenuConfig ?? wCommandMenuConfig}, ...extensionOptions, }, + markupConfig: { + extensions: markupConfigExtensions, + }, extraExtensions: (builder) => { builder .use(Math, { @@ -339,7 +349,7 @@ export const Playground = React.memo((props) => { className={b('editor-view')} stickyToolbar={Boolean(stickyToolbar)} wysiwygToolbarConfig={wysiwygToolbarConfig ?? wToolbarConfig} - markupToolbarConfig={mToolbarConfig} + markupToolbarConfig={markupToolbarConfig ?? mToolbarConfig} settingsVisible={settingsVisible} editor={mdEditor} enableSubmitInPreview={enableSubmitInPreview} diff --git a/demo/ghostExample/PlaygroundGhostExample.stories.tsx b/demo/ghostExample/PlaygroundGhostExample.stories.tsx new file mode 100644 index 000000000..461092d43 --- /dev/null +++ b/demo/ghostExample/PlaygroundGhostExample.stories.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +// eslint-disable-next-line import/no-extraneous-dependencies +import type {StoryFn} from '@storybook/react'; + +import {PlaygroundGhostExample} from './PlaygroundGhostExample'; + +export default { + title: 'Experiments / Popup in markup mode', + component: PlaygroundGhostExample, +}; + +type PlaygroundStoryProps = {}; +export const Playground: StoryFn = (props) => ( + +); + +Playground.storyName = 'Ghost'; diff --git a/demo/ghostExample/PlaygroundGhostExample.tsx b/demo/ghostExample/PlaygroundGhostExample.tsx new file mode 100644 index 000000000..798cb166b --- /dev/null +++ b/demo/ghostExample/PlaygroundGhostExample.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import cloneDeep from 'lodash/cloneDeep'; + +import {logger, markupToolbarConfigs} from '../../src'; +import {Playground} from '../Playground'; + +import {ghostPopupExtension, ghostPopupToolbarItem} from './ghostExtension'; +import {initialMdContent} from './md-content'; + +import '../Playground.scss'; + +logger.setLogger({ + metrics: console.info, + action: (data) => console.info(`Action: ${data.action}`, data), + ...console, +}); + +const mToolbarConfig = cloneDeep(markupToolbarConfigs.mToolbarConfig); + +mToolbarConfig[2].unshift(ghostPopupToolbarItem); + +export const PlaygroundGhostExample = React.memo(() => { + return ( + + ); +}); + +PlaygroundGhostExample.displayName = 'Ghost-example'; diff --git a/demo/ghostExample/README.md b/demo/ghostExample/README.md new file mode 100644 index 000000000..0c265f9c0 --- /dev/null +++ b/demo/ghostExample/README.md @@ -0,0 +1 @@ +This is an example for documentation on creating and adding a text-bound extension for markup mode. \ No newline at end of file diff --git a/demo/ghostExample/ghostExtension/commands.ts b/demo/ghostExample/ghostExtension/commands.ts new file mode 100644 index 000000000..9264a2da3 --- /dev/null +++ b/demo/ghostExample/ghostExtension/commands.ts @@ -0,0 +1,11 @@ +import type {EditorView} from '../../../src/cm/view'; + +import {HideGhostPopupEffect, ShowGhostPopupEffect} from './effects'; + +export const showGhostPopup = (view: EditorView) => { + view.dispatch({effects: [ShowGhostPopupEffect.of(null)]}); +}; + +export const hideGhostPopup = (view: EditorView) => { + view.dispatch({effects: [HideGhostPopupEffect.of(null)]}); +}; diff --git a/demo/ghostExample/ghostExtension/effects.ts b/demo/ghostExample/ghostExtension/effects.ts new file mode 100644 index 000000000..d90444ce6 --- /dev/null +++ b/demo/ghostExample/ghostExtension/effects.ts @@ -0,0 +1,4 @@ +import {StateEffect} from '../../../src/cm/state'; + +export const ShowGhostPopupEffect = StateEffect.define(); +export const HideGhostPopupEffect = StateEffect.define(); diff --git a/demo/ghostExample/ghostExtension/index.ts b/demo/ghostExample/ghostExtension/index.ts new file mode 100644 index 000000000..7a9ae1136 --- /dev/null +++ b/demo/ghostExample/ghostExtension/index.ts @@ -0,0 +1,6 @@ +import {GhostPopupPlugin} from './plugin'; + +export {ghostPopupToolbarItem} from './toolbar'; +export {showGhostPopup, hideGhostPopup} from './commands'; + +export const ghostPopupExtension = GhostPopupPlugin.extension; diff --git a/demo/ghostExample/ghostExtension/plugin.ts b/demo/ghostExample/ghostExtension/plugin.ts new file mode 100644 index 000000000..c9f1829f6 --- /dev/null +++ b/demo/ghostExample/ghostExtension/plugin.ts @@ -0,0 +1,108 @@ +import {ReactRendererFacet} from '../../../src'; +import { + Decoration, + type DecorationSet, + type EditorView, + type PluginValue, + ViewPlugin, + type ViewUpdate, + WidgetType, +} from '../../../src/cm/view'; + +import {hideGhostPopup} from './commands'; +import {HideGhostPopupEffect, ShowGhostPopupEffect} from './effects'; +import {renderPopup} from './popup'; + +const DECO_CLASS_NAME = 'ghost-example'; + +class SpanWidget extends WidgetType { + private className = ''; + private textContent = ''; + + constructor(className: string, textContent: string) { + super(); + this.className = className; + this.textContent = textContent; + } + + toDOM() { + const spanElem = document.createElement('span'); + spanElem.className = this.className; + spanElem.textContent = this.textContent; + return spanElem; + } +} + +export const GhostPopupPlugin = ViewPlugin.fromClass( + class implements PluginValue { + decos: DecorationSet = Decoration.none; + readonly _view: EditorView; + readonly _renderItem; + _anchor: Element | null = null; + + constructor(view: EditorView) { + this._view = view; + this._renderItem = view.state + .facet(ReactRendererFacet) + .createItem('ghost-popup-example-in-markup-mode', () => this.renderPopup()); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.selectionSet) { + this.decos = Decoration.none; + return; + } + + this.decos = this.decos.map(update.changes); + const {from, to} = update.state.selection.main; + + for (const tr of update.transactions) { + for (const eff of tr.effects) { + if (eff.is(ShowGhostPopupEffect)) { + if (from === to) { + const decorationWidget = Decoration.widget({ + widget: new SpanWidget(DECO_CLASS_NAME, ''), + }); + + this.decos = Decoration.set([decorationWidget.range(from)]); + + return; + } + + this.decos = Decoration.set([ + { + from, + to, + value: Decoration.mark({class: DECO_CLASS_NAME}), + }, + ]); + } + + if (eff.is(HideGhostPopupEffect)) { + this.decos = Decoration.none; + } + } + } + } + + docViewUpdate() { + this._anchor = this._view.dom.getElementsByClassName(DECO_CLASS_NAME).item(0); + this._renderItem.rerender(); + } + + destroy() { + this._renderItem.remove(); + } + + renderPopup() { + return this._anchor + ? renderPopup(this._anchor as HTMLElement, { + onClose: () => hideGhostPopup(this._view), + }) + : null; + } + }, + { + decorations: (value) => value.decos, + }, +); diff --git a/demo/ghostExample/ghostExtension/popup.tsx b/demo/ghostExample/ghostExtension/popup.tsx new file mode 100644 index 000000000..dcb2fcb0e --- /dev/null +++ b/demo/ghostExample/ghostExtension/popup.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import {Ghost} from '@gravity-ui/icons'; +import {Button, Popup} from '@gravity-ui/uikit'; + +type Props = { + onClose: () => void; +}; + +export function renderPopup(anchor: HTMLElement, props: Props) { + return ( + +
+ + +
+
+ ); +} diff --git a/demo/ghostExample/ghostExtension/toolbar.ts b/demo/ghostExample/ghostExtension/toolbar.ts new file mode 100644 index 000000000..de4cc835f --- /dev/null +++ b/demo/ghostExample/ghostExtension/toolbar.ts @@ -0,0 +1,16 @@ +import {Ghost} from '@gravity-ui/icons'; + +import {ToolbarDataType} from '../../../src'; +import {MToolbarSingleItemData} from '../../../src/bundle/config/markup'; + +import {showGhostPopup} from './commands'; + +export const ghostPopupToolbarItem: MToolbarSingleItemData = { + id: 'ghost', + type: ToolbarDataType.SingleButton, + title: 'Show ghost', + icon: {data: Ghost}, + exec: (e) => showGhostPopup(e.cm), + isActive: () => false, + isEnable: () => true, +}; diff --git a/demo/ghostExample/md-content.ts b/demo/ghostExample/md-content.ts new file mode 100644 index 000000000..010d18a31 --- /dev/null +++ b/demo/ghostExample/md-content.ts @@ -0,0 +1,9 @@ +export const initialMdContent = ` +This is an example of working with a popup in markup editor mode. To test it: + + 1. Switch to markup mode. + 2. Find the ghost icon in the toolbar. + 3. Click on it. + +Detailed documentation on how to create similar extensions can be found [here](https://github.com/gravity-ui/markdown-editor/blob/main/docs/how-to-add-text-binding-extension-in-markdown.md). +`; diff --git a/docs/how-to-add-text-binding-extension-in-markdown.md b/docs/how-to-add-text-binding-extension-in-markdown.md new file mode 100644 index 000000000..de6b70ea3 --- /dev/null +++ b/docs/how-to-add-text-binding-extension-in-markdown.md @@ -0,0 +1,247 @@ +## How to create an extension with popup for Markdown mode with text binding + +Let's consider connecting an extension with text binding based on the Ghost extension — this is a test extension. + +To begin with, we need the plugin itself, which we can implement as follows: + +```ts +// plugin.ts + +import {ReactRendererFacet} from '@gravity-ui/markdown-editor'; +import { + Decoration, + type DecorationSet, + type EditorView, + type PluginValue, + ViewPlugin, + type ViewUpdate, + WidgetType, +} from '@gravity-ui/markdown-editor/cm/view'; + +// We'll talk about it later +import {hideGhostPopup} from './commands'; +import {HideGhostPopupEffect, ShowGhostPopupEffect} from './effects'; +import {renderPopup} from './popup'; + +const DECO_CLASS_NAME = 'ghost-example'; + +// The class that will return the span element to the decoration +class SpanWidget extends WidgetType { + private className = ''; + private textContent = ''; + + constructor(className: string, textContent: string) { + super(); + this.className = className; + this.textContent = textContent; + } + + toDOM() { + const spanElem = document.createElement('span'); + spanElem.className = this.className; + spanElem.textContent = this.textContent; + return spanElem; + } +} + +// Using ViewPlugin.fromClass imported from CodeMirror +// It accepts an anonymous class and PluginSpec +export const GhostPopupPlugin = ViewPlugin.fromClass( + class implements PluginValue { + // The class allows you to implement the following methods: update, docViewUpdate, destroy. + + decos: DecorationSet = Decoration.none; + readonly _view: EditorView; + readonly _renderItem; + _anchor: Element | null = null; + + constructor(view: EditorView) { + // Saving the view and creating a renderItem + this._view = view; + this._renderItem = view.state + .facet(ReactRendererFacet) + .createItem('ghost-popup-example-in-markup-mode', () => this.renderPopup()); + } + + + // Called when transactions want to be applied to the view + update(update: ViewUpdate) { + if (update.docChanged || update.selectionSet) { + this.decos = Decoration.none; + return; + } + + this.decos = this.decos.map(update.changes); + const {from, to} = update.state.selection.main; + + for (const tr of update.transactions) { + for (const eff of tr.effects) { + // Check for the desired effect + if (eff.is(ShowGhostPopupEffect)) { + if (from === to) { + + // Creating a decoration + const decorationWidget = Decoration.widget({ + widget: new SpanWidget(DECO_CLASS_NAME, ''), + }); + + this.decos = Decoration.set([decorationWidget.range(from)]); + + return; + } + + this.decos = Decoration.set([ + { + from, + to, + value: Decoration.mark({class: DECO_CLASS_NAME}), + }, + ]); + } + + // If such an effect has come, then we cancel the decorations + if (eff.is(HideGhostPopupEffect)) { + this.decos = Decoration.none; + } + } + } + } + + // Is called after accepting a new state in the view and dom + docViewUpdate() { + // Save the link to our decoration + this._anchor = this._view.dom.getElementsByClassName(DECO_CLASS_NAME).item(0); + this._renderItem.rerender(); + } + + // Called when unmounting the view + destroy() { + this._renderItem.remove(); + } + + // Function for popup render + renderPopup() { + // Passing the link to our popup + return this._anchor + ? renderPopup(this._anchor as HTMLElement, { + onClose: () => hideGhostPopup(this._view), + }) + : null; + } + }, + // Used to draw decorations + { + // Value is a reference to our anonymous class and returns the decoration + decorations: (value) => value.decos, + }, +); +``` + +Let's create a popup that will be linked to the text. + +This is a simple component that takes a link and renders a popup in its place +```ts +// popup.ts +import React from 'react'; + +import {Ghost} from '@gravity-ui/icons'; +import {Button, Popup} from '@gravity-ui/uikit'; + +type Props = { + onClose: () => void; +}; + +export function renderPopup(anchor: HTMLElement, props: Props) { + return ( + +
+ + +
+
+ ); +} +``` + +Let's create a button for the markup toolbar. + +```ts +// toolbar ts + +import {Ghost} from '@gravity-ui/icons'; + +import {ToolbarDataType} from '@gravity-ui/markdown-editor'; +import {MToolbarSingleItemData} from '@gravity-ui/markdown-editor/bundle/config/markup'; + +import {showGhostPopup} from './commands'; + +export const ghostPopupToolbarItem: MToolbarSingleItemData = { + id: 'ghost', + type: ToolbarDataType.SingleButton, + title: 'Show ghost', + icon: {data: Ghost}, + exec: (e) => showGhostPopup(e.cm), + isActive: () => false, + isEnable: () => true, +}; +``` + +Let's create commands to display the plugin. + +```ts +// commands.ts + +import type {EditorView} from '@gravity-ui/markdown-editor/cm/view'; + + +import {StateEffect} from '@gravity-ui/markdown-editor/cm/state'; +// Empty effects without parameters +export const ShowGhostPopupEffect = StateEffect.define(); +export const HideGhostPopupEffect = StateEffect.define(); + + +// Creating events that will trigger the plugin +// Effect on the similarity of meta information for update, we use it as a Boolean flag +export const showGhostPopup = (view: EditorView) => { + view.dispatch({effects: [ShowGhostPopupEffect.of(null)]}); +}; + +export const hideGhostPopup = (view: EditorView) => { + view.dispatch({effects: [HideGhostPopupEffect.of(null)]}); +}; + +``` + +Everything is ready, all that remains is to connect the extension to the editor. + +```ts + +import {markupToolbarConfigs, useMarkdownEditor} from '@gravity-ui/markdown-editor'; +import {ghostPopupExtension} from './ghostExtension'; +import {ghostPopupToolbarItem} from './toolbar'; + +const mToolbarConfig = [...markupToolbarConfigs.mToolbarConfig,]; + +mToolbarConfig.mToolbarConfig.push(ghostPopupToolbarItem); + +const mdEditor = useMarkdownEditor({ + // ... + // Add extension + markupConfig: { + extensions: [ghostPopupExtension], + }, +}); + +return +``` + +Now you can use the plugin! +You can also check out the GPT implementation or see the Ghost example on the Playground and in the code. \ No newline at end of file