From 54ac0d36499e572844e42cfff8f7781387731b00 Mon Sep 17 00:00:00 2001 From: Yuriy Demidov Date: Tue, 10 Dec 2024 15:58:23 +0300 Subject: [PATCH 01/19] feat(bundle): update view of text color action item in toolbar (#514) --- .../toolbar/ToolbarButtonWithPopupMenu.tsx | 25 +++++++++++++++---- src/bundle/toolbar/custom/ToolbarColors.scss | 9 +++++++ src/bundle/toolbar/custom/ToolbarColors.tsx | 3 +++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/bundle/toolbar/ToolbarButtonWithPopupMenu.tsx b/src/bundle/toolbar/ToolbarButtonWithPopupMenu.tsx index f225d53e..03d0afdd 100644 --- a/src/bundle/toolbar/ToolbarButtonWithPopupMenu.tsx +++ b/src/bundle/toolbar/ToolbarButtonWithPopupMenu.tsx @@ -26,8 +26,12 @@ export type MenuItem = { export type ToolbarButtonWithPopupMenuProps = Omit< ToolbarBaseProps & { icon: ToolbarIconData; + iconClassName?: string; + chevronIconClassName?: string; title: string | (() => string); menuItems: MenuItem[]; + /** @default 'classic' */ + _selectionType?: 'classic' | 'light'; }, 'editor' >; @@ -37,8 +41,11 @@ export const ToolbarButtonWithPopupMenu: React.FC { const buttonRef = React.useRef(null); const [open, , hide, toggleOpen] = useBooleanState(false); @@ -48,7 +55,7 @@ export const ToolbarButtonWithPopupMenu: React.FC ({...i, group: i.group || ''})), 'group', ), - [menuItems, groupBy], + [menuItems], ); const someActive = menuItems.some( @@ -64,6 +71,14 @@ export const ToolbarButtonWithPopupMenu: React.FC - + {''} - + diff --git a/src/bundle/toolbar/custom/ToolbarColors.scss b/src/bundle/toolbar/custom/ToolbarColors.scss index 1b573159..e5e8bcd4 100644 --- a/src/bundle/toolbar/custom/ToolbarColors.scss +++ b/src/bundle/toolbar/custom/ToolbarColors.scss @@ -2,10 +2,19 @@ $colors: ('gray', 'yellow', 'orange', 'red', 'green', 'blue', 'violet'); .g-md-toolbar-colors { @each $name in $colors { + &__menu-icon_color_#{$name} { + color: var(--yfm-colorify-#{$name}); + } + + &__chevron-icon_color_#{$name} { + color: var(--yfm-colorify-#{$name}); + } + &__item-icon_color_#{$name} { color: var(--yfm-colorify-#{$name}); } } + &__item-icon_color_default { color: var(--g-color-text-primary); } diff --git a/src/bundle/toolbar/custom/ToolbarColors.tsx b/src/bundle/toolbar/custom/ToolbarColors.tsx index 68b087a5..1633e6c9 100644 --- a/src/bundle/toolbar/custom/ToolbarColors.tsx +++ b/src/bundle/toolbar/custom/ToolbarColors.tsx @@ -77,6 +77,9 @@ export const ToolbarColors: React.FC = (props) => { title={i18n('colorify')} menuItems={items} icon={textColorIcon} + _selectionType="light" + iconClassName={b('menu-icon', {color: currentColor})} + chevronIconClassName={b('chevron-icon', {color: currentColor})} /> ); }; From dac5314615c3808a086ba33ebe19b0063fdb6ffd Mon Sep 17 00:00:00 2001 From: Gravity UI Bot <111915794+gravity-ui-bot@users.noreply.github.com> Date: Thu, 12 Dec 2024 17:10:58 +0300 Subject: [PATCH 02/19] chore(main): release 14.6.0 (#513) --- CHANGELOG.md | 14 ++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5bfbbe5..d7adad77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [14.6.0](https://github.com/gravity-ui/markdown-editor/compare/v14.5.1...v14.6.0) (2024-12-10) + + +### Features + +* **bundle:** update view of text color action item in toolbar ([#514](https://github.com/gravity-ui/markdown-editor/issues/514)) ([54ac0d3](https://github.com/gravity-ui/markdown-editor/commit/54ac0d36499e572844e42cfff8f7781387731b00)) + + +### Bug Fixes + +* **Cursor:** input-rules does not work when cursor in virtual selection (GapCursorSelection) ([#515](https://github.com/gravity-ui/markdown-editor/issues/515)) ([9126756](https://github.com/gravity-ui/markdown-editor/commit/9126756fe5e241c6ab2badec4689b1df8f0009c3)) +* **deps:** bumped @lezer/markdown to fix large text hang ([#512](https://github.com/gravity-ui/markdown-editor/issues/512)) ([8a8fce8](https://github.com/gravity-ui/markdown-editor/commit/8a8fce8ff5f9603f6e755264fc474c03a36d6bb7)) +* Gpt extension render ([#519](https://github.com/gravity-ui/markdown-editor/issues/519)) ([89c9881](https://github.com/gravity-ui/markdown-editor/commit/89c9881331df2b0fae5968258a29b9c9eed179ef)) + ## [14.5.1](https://github.com/gravity-ui/markdown-editor/compare/v14.5.0...v14.5.1) (2024-12-02) diff --git a/package-lock.json b/package-lock.json index bbd9767a..8c6fdb39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@gravity-ui/markdown-editor", - "version": "14.5.1", + "version": "14.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gravity-ui/markdown-editor", - "version": "14.5.1", + "version": "14.6.0", "license": "MIT", "dependencies": { "@bem-react/classname": "^1.6.0", diff --git a/package.json b/package.json index 3fe94296..e46574e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gravity-ui/markdown-editor", - "version": "14.5.1", + "version": "14.6.0", "description": "Markdown wysiwyg and markup editor", "license": "MIT", "repository": { From 4355519ef4310e37f322b0313e0281880a4b5315 Mon Sep 17 00:00:00 2001 From: Rich Voronov Date: Tue, 17 Dec 2024 09:43:10 +0300 Subject: [PATCH 03/19] build: added react 19 support for peerDependencies (#524) --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index e46574e3..754bae7a 100644 --- a/package.json +++ b/package.json @@ -300,8 +300,8 @@ "lodash": "^4.17.20", "lowlight": "^3.0.0", "markdown-it": "^13.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "lint-staged": { "*.{css,scss}": [ From dc049af1c5d3a1016406afec3237b85bad2211c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE=D0=B2=D0=B8=D1=87=20=D0=9C?= =?UTF-8?q?=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=90=D0=BD=D1=82=D0=BE=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B8=D1=87?= <87013925+PMAWorks@users.noreply.github.com> Date: Tue, 17 Dec 2024 17:05:45 +0300 Subject: [PATCH 04/19] feat(bundle): added empty row placeholder (#506) --- demo/components/Playground.tsx | 6 ++++++ src/bundle/types.ts | 12 ++++++++++++ src/bundle/useMarkdownEditor.ts | 1 + src/bundle/wysiwyg-preset.ts | 22 ++++++++++++++++++---- 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/demo/components/Playground.tsx b/demo/components/Playground.tsx index 3492b81f..b6c5708f 100644 --- a/demo/components/Playground.tsx +++ b/demo/components/Playground.tsx @@ -16,6 +16,7 @@ import { type RenderPreview, type ToolbarGroupData, type UseMarkdownEditorProps, + WysiwygPlaceholderOptions, logger, markupToolbarConfigs, useMarkdownEditor, @@ -79,6 +80,7 @@ export type PlaygroundProps = { breaks?: boolean; linkify?: boolean; linkifyTlds?: string | string[]; + placeholderOptions?: WysiwygPlaceholderOptions; sanitizeHtml?: boolean; prepareRawMarkup?: boolean; splitModeOrientation?: 'horizontal' | 'vertical' | false; @@ -139,6 +141,7 @@ export const Playground = React.memo((props) => { wysiwygCommandMenuConfig, markupConfigExtensions, markupToolbarConfig, + placeholderOptions, escapeConfig, enableSubmitInPreview, hidePreviewAfterSubmit, @@ -185,6 +188,9 @@ export const Playground = React.memo((props) => { needToSetDimensionsForUploadedImages, renderPreview: renderPreviewDefined ? renderPreview : undefined, fileUploadHandler, + wysiwygConfig: { + placeholderOptions: placeholderOptions, + }, experimental: { ...experimental, directiveSyntax, diff --git a/src/bundle/types.ts b/src/bundle/types.ts index 34d17136..b25ad335 100644 --- a/src/bundle/types.ts +++ b/src/bundle/types.ts @@ -26,6 +26,17 @@ export type RenderPreview = (params: RenderPreviewParams) => ReactNode; export type ParseInsertedUrlAsImage = (text: string) => {imageUrl: string; title?: string} | null; +export type WysiwygPlaceholderOptions = { + value?: string | (() => string); + /** Default – empty-doc + Values: + - 'empty-doc' – The placeholder will only be shown when the document is completely empty; + - 'empty-row-top-level' – The placeholder will be displayed in an empty line that is at the top level of the document structure; + - 'empty-row' – The placeholder will be shown in any empty line within the document, regardless of its nesting level. + */ + behavior?: 'empty-doc' | 'empty-row-top-level' | 'empty-row'; +}; + export type MarkdownEditorMdOptions = { html?: boolean; breaks?: boolean; @@ -148,6 +159,7 @@ export type MarkdownEditorWysiwygConfig = { extensions?: Extension; extensionOptions?: ExtensionsOptions; escapeConfig?: EscapeConfig; + placeholderOptions?: WysiwygPlaceholderOptions; }; // [major] TODO: remove generic type diff --git a/src/bundle/useMarkdownEditor.ts b/src/bundle/useMarkdownEditor.ts index b44cb463..6cdb1a7c 100644 --- a/src/bundle/useMarkdownEditor.ts +++ b/src/bundle/useMarkdownEditor.ts @@ -59,6 +59,7 @@ export function useMarkdownEditor( editor.emit('submit', null); return true; }, + placeholderOptions: wysiwygConfig.placeholderOptions, mdBreaks: breaks, fileUploadHandler: uploadFile, needToSetDimensionsForUploadedImages, diff --git a/src/bundle/wysiwyg-preset.ts b/src/bundle/wysiwyg-preset.ts index 42874148..af43bc4a 100644 --- a/src/bundle/wysiwyg-preset.ts +++ b/src/bundle/wysiwyg-preset.ts @@ -16,7 +16,7 @@ import type {FileUploadHandler} from '../utils/upload'; import {wCommandMenuConfigByPreset, wSelectionMenuConfigByPreset} from './config/wysiwyg'; import {emojiDefs} from './emoji'; -import type {MarkdownEditorPreset} from './types'; +import type {MarkdownEditorPreset, WysiwygPlaceholderOptions} from './types'; const DEFAULT_IGNORED_KEYS = ['Tab', 'Shift-Tab'] as const; @@ -27,6 +27,7 @@ export type BundlePresetOptions = ExtensionsOptions & preset: MarkdownEditorPreset; mdBreaks?: boolean; fileUploadHandler?: FileUploadHandler; + placeholderOptions?: WysiwygPlaceholderOptions; /** * If we need to set dimensions for uploaded images * @@ -63,9 +64,22 @@ export const BundlePreset: ExtensionAuto = (builder, opts) baseSchema: { paragraphKey: f.toPM(A.Text), paragraphPlaceholder: (node: Node, parent?: Node | null) => { - const isDocEmpty = - !node.text && parent?.type.name === BaseNode.Doc && parent.childCount === 1; - return isDocEmpty ? i18nPlaceholder('doc_empty') : null; + const {value, behavior} = opts.placeholderOptions || {}; + + const emptyEntries = { + 'empty-row': !node.text, + 'empty-row-top-level': !node.text && parent?.type.name === BaseNode.Doc, + 'empty-doc': + !node.text && parent?.type.name === BaseNode.Doc && parent.childCount === 1, + }; + + const showPlaceholder = emptyEntries[behavior || 'empty-doc']; + + if (!showPlaceholder) return null; + + return typeof value === 'function' + ? value() + : value ?? i18nPlaceholder('doc_empty'); }, ...opts.baseSchema, }, From a7c23b59af7f2d7a8fd52e3cdb927468854f6c09 Mon Sep 17 00:00:00 2001 From: Yuriy Demidov Date: Tue, 17 Dec 2024 19:29:59 +0300 Subject: [PATCH 05/19] fix(Checkbox): added parse dom rules and fixed pasting of checkboxes (#523) --- src/extensions/yfm/Checkbox/Checkbox.test.ts | 31 +++++++++- .../yfm/Checkbox/CheckboxSpecs/const.ts | 5 ++ .../yfm/Checkbox/CheckboxSpecs/index.ts | 12 ++-- .../yfm/Checkbox/CheckboxSpecs/schema.ts | 58 ++++++++++++++++--- src/extensions/yfm/Checkbox/index.ts | 2 + .../yfm/Checkbox/plugins/fix-paste.ts | 22 +++++++ src/utils/schema.ts | 2 +- tests/parse-dom.ts | 7 ++- 8 files changed, 122 insertions(+), 17 deletions(-) create mode 100644 src/extensions/yfm/Checkbox/plugins/fix-paste.ts diff --git a/src/extensions/yfm/Checkbox/Checkbox.test.ts b/src/extensions/yfm/Checkbox/Checkbox.test.ts index c23b69d9..cfbae01e 100644 --- a/src/extensions/yfm/Checkbox/Checkbox.test.ts +++ b/src/extensions/yfm/Checkbox/Checkbox.test.ts @@ -1,11 +1,13 @@ import {builders} from 'prosemirror-test-builder'; +import {parseDOM} from '../../../../tests/parse-dom'; import {createMarkupChecker} from '../../../../tests/sameMarkup'; import {ExtensionsManager} from '../../../core'; import {BaseNode, BaseSchemaSpecs} from '../../base/specs'; import {BoldSpecs, boldMarkName} from '../../markdown/specs'; -import {CheckboxNode, CheckboxSpecs} from './CheckboxSpecs'; +import {CheckboxAttr, CheckboxNode, CheckboxSpecs} from './CheckboxSpecs'; +import {fixPastePlugin} from './plugins/fix-paste'; const { schema, @@ -96,4 +98,31 @@ describe('Checkbox extension', () => { '[ ] checkbox-placeholder', ); }); + + it('should parse dom with checkbox', () => { + parseDOM( + schema, + ` + +
+ + +
`, + doc(checkbox(cbInput({[CheckboxAttr.Checked]: 'true'}), cbLabel('два'))), + [fixPastePlugin()], + ); + }); + + it('should parse dom with input[type=checkbox]', () => { + parseDOM( + schema, + ` + + + +`, + doc(checkbox(cbInput(), cbLabel('todo2'))), + [fixPastePlugin()], + ); + }); }); diff --git a/src/extensions/yfm/Checkbox/CheckboxSpecs/const.ts b/src/extensions/yfm/Checkbox/CheckboxSpecs/const.ts index cdf2aceb..72d88c00 100644 --- a/src/extensions/yfm/Checkbox/CheckboxSpecs/const.ts +++ b/src/extensions/yfm/Checkbox/CheckboxSpecs/const.ts @@ -1,4 +1,5 @@ import {cn} from '../../../../classname'; +import {nodeTypeFactory} from '../../../../utils/schema'; export enum CheckboxNode { Checkbox = 'checkbox', @@ -17,3 +18,7 @@ export const CheckboxAttr = { export const idPrefix = 'yfm-editor-checkbox'; export const b = cn('checkbox'); + +export const checkboxType = nodeTypeFactory(CheckboxNode.Checkbox); +export const checkboxLabelType = nodeTypeFactory(CheckboxNode.Label); +export const checkboxInputType = nodeTypeFactory(CheckboxNode.Input); diff --git a/src/extensions/yfm/Checkbox/CheckboxSpecs/index.ts b/src/extensions/yfm/Checkbox/CheckboxSpecs/index.ts index 72b01332..edfb2dc1 100644 --- a/src/extensions/yfm/Checkbox/CheckboxSpecs/index.ts +++ b/src/extensions/yfm/Checkbox/CheckboxSpecs/index.ts @@ -2,17 +2,19 @@ import checkboxPlugin from '@diplodoc/transform/lib/plugins/checkbox'; import type {NodeSpec} from 'prosemirror-model'; import type {ExtensionAuto, ExtensionNodeSpec} from '../../../../core'; -import {nodeTypeFactory} from '../../../../utils/schema'; import {CheckboxNode, b, idPrefix} from './const'; import {parserTokens} from './parser'; import {getSchemaSpecs} from './schema'; import {serializerTokens} from './serializer'; -export {CheckboxAttr, CheckboxNode} from './const'; -export const checkboxType = nodeTypeFactory(CheckboxNode.Checkbox); -export const checkboxLabelType = nodeTypeFactory(CheckboxNode.Label); -export const checkboxInputType = nodeTypeFactory(CheckboxNode.Input); +export { + CheckboxAttr, + CheckboxNode, + checkboxType, + checkboxLabelType, + checkboxInputType, +} from './const'; export type CheckboxSpecsOptions = { /** diff --git a/src/extensions/yfm/Checkbox/CheckboxSpecs/schema.ts b/src/extensions/yfm/Checkbox/CheckboxSpecs/schema.ts index 3210ec87..d8af22f2 100644 --- a/src/extensions/yfm/Checkbox/CheckboxSpecs/schema.ts +++ b/src/extensions/yfm/Checkbox/CheckboxSpecs/schema.ts @@ -1,8 +1,8 @@ -import type {NodeSpec} from 'prosemirror-model'; +import {Fragment, type NodeSpec} from 'prosemirror-model'; -import {PlaceholderOptions} from '../../../../utils/placeholder'; +import type {PlaceholderOptions} from '../../../../utils/placeholder'; -import {CheckboxAttr, CheckboxNode, b} from './const'; +import {CheckboxAttr, CheckboxNode, b, checkboxInputType, checkboxLabelType} from './const'; import type {CheckboxSpecsOptions} from './index'; @@ -13,14 +13,51 @@ export const getSchemaSpecs = ( placeholder?: PlaceholderOptions, ): Record => ({ [CheckboxNode.Checkbox]: { - group: 'block', + group: 'block checkbox', content: `${CheckboxNode.Input} ${CheckboxNode.Label}`, selectable: true, allowSelection: false, - parseDOM: [], attrs: { [CheckboxAttr.Class]: {default: b()}, }, + parseDOM: [ + { + tag: 'div.checkbox', + priority: 100, + getContent(node, schema) { + const input = (node as HTMLElement).querySelector( + 'input[type=checkbox]', + ); + const label = (node as HTMLElement).querySelector( + 'label[for]', + ); + + const checked = input?.checked ? 'true' : null; + const text = label?.textContent; + + return Fragment.from([ + checkboxInputType(schema).create({[CheckboxAttr.Checked]: checked}), + checkboxLabelType(schema).create(null, text ? schema.text(text) : null), + ]); + }, + }, + { + tag: 'input[type=checkbox]', + priority: 50, + getContent(node, schema) { + const id = (node as HTMLElement).id; + const checked = (node as HTMLInputElement).checked ? 'true' : null; + const text = node.parentNode?.querySelector( + `label[for=${id}]`, + )?.textContent; + + return Fragment.from([ + checkboxInputType(schema).create({[CheckboxAttr.Checked]: checked}), + checkboxLabelType(schema).create(null, text ? schema.text(text) : null), + ]); + }, + }, + ], toDOM(node) { return ['div', node.attrs, 0]; }, @@ -28,7 +65,7 @@ export const getSchemaSpecs = ( }, [CheckboxNode.Input]: { - group: 'block', + group: 'block checkbox', parseDOM: [], attrs: { [CheckboxAttr.Type]: {default: 'checkbox'}, @@ -45,7 +82,7 @@ export const getSchemaSpecs = ( [CheckboxNode.Label]: { content: 'inline*', - group: 'block', + group: 'block checkbox', parseDOM: [ { tag: `span[class="${b('label')}"]`, @@ -53,6 +90,13 @@ export const getSchemaSpecs = ( [CheckboxAttr.For]: (node as Element).getAttribute(CheckboxAttr.For) || '', }), }, + { + // input handled by checkbox node parse rule + // ignore label + tag: 'input[type=checkbox] ~ label[for]', + ignore: true, + consuming: true, + }, ], attrs: { [CheckboxAttr.For]: {default: null}, diff --git a/src/extensions/yfm/Checkbox/index.ts b/src/extensions/yfm/Checkbox/index.ts index 4ca8c867..715d441f 100644 --- a/src/extensions/yfm/Checkbox/index.ts +++ b/src/extensions/yfm/Checkbox/index.ts @@ -5,6 +5,7 @@ import {CheckboxSpecs, type CheckboxSpecsOptions} from './CheckboxSpecs'; import {addCheckbox} from './actions'; import {CheckboxInputView} from './nodeviews'; import {keymapPlugin} from './plugin'; +import {fixPastePlugin} from './plugins/fix-paste'; import {checkboxInputType, checkboxType} from './utils'; import './index.scss'; @@ -29,6 +30,7 @@ export const Checkbox: ExtensionAuto = (builder, opts) => { builder .addPlugin(keymapPlugin, builder.Priority.High) + .addPlugin(fixPastePlugin) .addAction(checkboxAction, () => addCheckbox()) .addInputRules(({schema}) => ({ rules: [ diff --git a/src/extensions/yfm/Checkbox/plugins/fix-paste.ts b/src/extensions/yfm/Checkbox/plugins/fix-paste.ts new file mode 100644 index 00000000..eccf83a7 --- /dev/null +++ b/src/extensions/yfm/Checkbox/plugins/fix-paste.ts @@ -0,0 +1,22 @@ +import {Slice} from 'prosemirror-model'; +import {Plugin} from 'prosemirror-state'; + +import {checkboxType} from '../CheckboxSpecs'; + +export const fixPastePlugin = () => + new Plugin({ + props: { + transformPasted(slice) { + const {firstChild} = slice.content; + if (firstChild && firstChild.type === checkboxType(firstChild.type.schema)) { + // When paste html with checkboxes and checkbox is first node, + // pm creates slice with broken openStart and openEnd. + // And content is inserted without a container block for checkboxes. + // It is fixed by create new slice with zeroed openStart and openEnd. + return new Slice(slice.content, 0, 0); + } + + return slice; + }, + }, + }); diff --git a/src/utils/schema.ts b/src/utils/schema.ts index cd28b2ca..0d7addf0 100644 --- a/src/utils/schema.ts +++ b/src/utils/schema.ts @@ -1,4 +1,4 @@ -import {Node, NodeType, Schema} from 'prosemirror-model'; +import {Node, type NodeType, type Schema} from 'prosemirror-model'; export const nodeTypeFactory = (nodeName: string) => (schema: Schema) => schema.nodes[nodeName]; export const markTypeFactory = (markName: string) => (schema: Schema) => schema.marks[markName]; diff --git a/tests/parse-dom.ts b/tests/parse-dom.ts index a6c4763d..e1df6b7f 100644 --- a/tests/parse-dom.ts +++ b/tests/parse-dom.ts @@ -1,11 +1,12 @@ /* eslint-disable no-implicit-globals */ import type {Node, Schema} from 'prosemirror-model'; -import {EditorState} from 'prosemirror-state'; +import {EditorState, type Plugin} from 'prosemirror-state'; import {EditorView} from 'prosemirror-view'; + import {dispatchPasteEvent} from './dispatch-event'; -export function parseDOM(schema: Schema, html: string, doc: Node): void { - const view = new EditorView(null, {state: EditorState.create({schema})}); +export function parseDOM(schema: Schema, html: string, doc: Node, plugins?: Plugin[]): void { + const view = new EditorView(null, {state: EditorState.create({schema}), plugins}); dispatchPasteEvent(view, {'text/html': html}); expect(view.state.doc).toMatchNode(doc); } From 9e54ad2bbfd680d445acf41e2c44bec97f67a17d Mon Sep 17 00:00:00 2001 From: Gravity UI Bot <111915794+gravity-ui-bot@users.noreply.github.com> Date: Tue, 17 Dec 2024 20:15:16 +0300 Subject: [PATCH 06/19] chore(main): release 14.7.0 (#525) --- CHANGELOG.md | 12 ++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7adad77..398adbf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [14.7.0](https://github.com/gravity-ui/markdown-editor/compare/v14.6.0...v14.7.0) (2024-12-17) + + +### Features + +* **bundle:** added empty row placeholder ([#506](https://github.com/gravity-ui/markdown-editor/issues/506)) ([dc049af](https://github.com/gravity-ui/markdown-editor/commit/dc049af1c5d3a1016406afec3237b85bad2211c0)) + + +### Bug Fixes + +* **Checkbox:** added parse dom rules and fixed pasting of checkboxes ([#523](https://github.com/gravity-ui/markdown-editor/issues/523)) ([a7c23b5](https://github.com/gravity-ui/markdown-editor/commit/a7c23b59af7f2d7a8fd52e3cdb927468854f6c09)) + ## [14.6.0](https://github.com/gravity-ui/markdown-editor/compare/v14.5.1...v14.6.0) (2024-12-10) diff --git a/package-lock.json b/package-lock.json index 8c6fdb39..34202627 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@gravity-ui/markdown-editor", - "version": "14.6.0", + "version": "14.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gravity-ui/markdown-editor", - "version": "14.6.0", + "version": "14.7.0", "license": "MIT", "dependencies": { "@bem-react/classname": "^1.6.0", diff --git a/package.json b/package.json index 754bae7a..b29f2da3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gravity-ui/markdown-editor", - "version": "14.6.0", + "version": "14.7.0", "description": "Markdown wysiwyg and markup editor", "license": "MIT", "repository": { From 03b39624c32adde84adae74c4e320ce389d0eddb Mon Sep 17 00:00:00 2001 From: Kirill Kharitonov Date: Tue, 17 Dec 2024 18:15:48 +0100 Subject: [PATCH 07/19] feat(build): added a sideEffects property for tree shaking package (#522) --- package.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index b29f2da3..2a4ab3ee 100644 --- a/package.json +++ b/package.json @@ -313,5 +313,13 @@ "prettier --write" ], "*.{md,json,yaml,yml}": "prettier --write" - } + }, + "sideEffects": [ + "*.css", + "*.scss", + "src/shortcuts/index.ts", + "src/shortcuts/default.ts", + "build/**/shortcuts/index.js", + "build/**/shortcuts/default.js" + ] } From 0d74a7c8c6d5cc45c7d7f7296b374e7b966a78be Mon Sep 17 00:00:00 2001 From: Gravity UI Bot <111915794+gravity-ui-bot@users.noreply.github.com> Date: Tue, 17 Dec 2024 20:20:54 +0300 Subject: [PATCH 08/19] chore(main): release 14.8.0 (#526) --- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 398adbf8..a4c24db0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [14.8.0](https://github.com/gravity-ui/markdown-editor/compare/v14.7.0...v14.8.0) (2024-12-17) + + +### Features + +* **build:** added a sideEffects property for tree shaking package ([#522](https://github.com/gravity-ui/markdown-editor/issues/522)) ([03b3962](https://github.com/gravity-ui/markdown-editor/commit/03b39624c32adde84adae74c4e320ce389d0eddb)) + ## [14.7.0](https://github.com/gravity-ui/markdown-editor/compare/v14.6.0...v14.7.0) (2024-12-17) diff --git a/package-lock.json b/package-lock.json index 34202627..cffa164d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@gravity-ui/markdown-editor", - "version": "14.7.0", + "version": "14.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gravity-ui/markdown-editor", - "version": "14.7.0", + "version": "14.8.0", "license": "MIT", "dependencies": { "@bem-react/classname": "^1.6.0", diff --git a/package.json b/package.json index 2a4ab3ee..1c280ed6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gravity-ui/markdown-editor", - "version": "14.7.0", + "version": "14.8.0", "description": "Markdown wysiwyg and markup editor", "license": "MIT", "repository": { From 5a82a6cdd7236bf3abf779a6a04df96daae48bb5 Mon Sep 17 00:00:00 2001 From: vvtimofeev <108340247+vvtimofeev@users.noreply.github.com> Date: Wed, 18 Dec 2024 18:36:50 +0300 Subject: [PATCH 09/19] docs: add ru readme (#527) --- README-ru.md | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 16 +++++----- 2 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 README-ru.md diff --git a/README-ru.md b/README-ru.md new file mode 100644 index 00000000..6c8269e5 --- /dev/null +++ b/README-ru.md @@ -0,0 +1,86 @@ +![Markdown Editor](https://github.com/user-attachments/assets/0b4e5f65-54cf-475f-9c68-557a4e9edb46) + +# @gravity-ui/markdown-editor · [![npm package](https://img.shields.io/npm/v/@gravity-ui/markdown-editor)](https://www.npmjs.com/package/@gravity-ui/markdown-editor) [![CI](https://img.shields.io/github/actions/workflow/status/gravity-ui/markdown-editor/ci.yml?branch=main&label=CI)](https://github.com/gravity-ui/markdown-editor/actions/workflows/ci.yml?query=branch:main) [![Release](https://img.shields.io/github/actions/workflow/status/gravity-ui/markdown-editor/release.yml?branch=main&label=Release)](https://github.com/gravity-ui/markdown-editor/actions/workflows/release.yml?query=branch:main) [![storybook](https://img.shields.io/badge/Storybook-deployed-ff4685)](https://preview.gravity-ui.com/md-editor/) + +## Редактор Markdown с поддержкой режимов WYSIWYG и Markup + +`MarkdownEditor` — эффективный инструмент для работы с Markdown, сочетающий режимы WYSIWYG и Markup. Он позволяет создавать и редактировать контент в удобном визуальном режиме с полным контролем над разметкой. + +### 🔧 Основные характеристики + +- Поддержка базового синтаксиса Markdown и [YFM](https://ydocs.tech). +- Расширяемость за счет использования движков ProseMirror и CodeMirror. +- Возможность работы в режимах WYSIWYG и Markup для максимальной гибкости. + +## Установка + +```shell +npm install @gravity-ui/markdown-editor +``` + +### Необходимые зависимости + +Для начала работы с пакетом в проекте необходимо предварительно установить следующие зависимости: `@diplodoc/transform`, `react`, `react-dom` и др. Подробную информацию можно найти в разделе `peerDependencies` файла `package.json`. + +## Начало работы + +`MarkdownEditor` поставляется в виде React-хука для создания экземпляра редактора и компонента для рендеринга представления. +Для настройки стиля и темы см. [документацию UIKit](https://github.com/gravity-ui/uikit?tab=readme-ov-file#styles). + +```tsx +import React from 'react'; +import {useMarkdownEditor, MarkdownEditorView} from '@gravity-ui/markdown-editor'; +import {toaster} from '@gravity-ui/uikit/toaster-singleton-react-18'; + +function Editor({onSubmit}) { + const editor = useMarkdownEditor({allowHTML: false}); + + React.useEffect(() => { + function submitHandler() { + // Serialize current content to markdown markup + const value = editor.getValue(); + onSubmit(value); + } + + editor.on('submit', submitHandler); + return () => { + editor.off('submit', submitHandler); + }; + }, [onSubmit]); + + return ; +} +``` +Полезные ссылки: +- [Как подключить редактор в Create React App](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-install-create-react-app--docs) +- [Как добавить предварительный просмотр для режима разметки](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-develop-preview--docs) +- [Как добавить расширение HTML](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-connect-html-block--docs) +- [Как добавить расширение Latex](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-connect-latex-extension--docs) +- [Как добавить расширение Mermaid](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-connect-mermaid-extension--docs) +- [Как создать собственное расширение](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-develop-extension-creation--docs) +- [Как добавить расширение GPT](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-connect-gpt--docs) +- [Как добавить расширение привязки текста в Markdown](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-develop-extension-with-popup--docs) + + + +### i18n + +Для настройки интернационализации используйте `configure`: + +```typescript +import {configure} from '@gravity-ui/markdown-editor'; + +configure({ + lang: 'ru', +}); +``` + +Обязательно сделайте вызов `configure()` из [UIKit](https://github.com/gravity-ui/uikit?tab=readme-ov-file#i18n) и других UI-библиотек. + +## Разработка + +Для запуска Storybook в режиме разработки выполните следующую команду: + +```shell +npm start +``` diff --git a/README.md b/README.md index 003baf43..bfbea225 100644 --- a/README.md +++ b/README.md @@ -52,14 +52,14 @@ function Editor({onSubmit}) { } ``` Read more: -- [How to connect the editor in the Create React App](https://github.com/gravity-ui/markdown-editor/blob/main/docs/how-to-add-editor-with-create-react-app.md) -- [How to add preview for markup mode](https://github.com/gravity-ui/markdown-editor/blob/main/docs/how-to-add-preview.md) -- [How to add HTML extension](https://github.com/gravity-ui/markdown-editor/blob/main/docs/how-to-connect-html-extension.md) -- [How to add Latex extension](https://github.com/gravity-ui/markdown-editor/blob/main/docs/how-to-connect-latex-extension.md) -- [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) +- [How to connect the editor in the Create React App](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-install-create-react-app--docs) +- [How to add preview for markup mode](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-develop-preview--docs) +- [How to add HTML extension](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-connect-html-block--docs) +- [How to add Latex extension](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-connect-latex-extension--docs) +- [How to add Mermaid extension](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-connect-mermaid-extension--docs) +- [How to write extension](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-develop-extension-creation--docs) +- [How to add GPT extension](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-connect-gpt--docs) +- [How to add text binding extension in markdown](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-develop-extension-with-popup--docs) From bd52bee93aceaf0af5bd9b8da284e93338b89a32 Mon Sep 17 00:00:00 2001 From: Yuriy Demidov Date: Thu, 19 Dec 2024 12:05:17 +0300 Subject: [PATCH 10/19] fix(Link): fixed pasting link to empty selection (#528) --- src/extensions/markdown/Link/paste-plugin.ts | 32 ++++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/extensions/markdown/Link/paste-plugin.ts b/src/extensions/markdown/Link/paste-plugin.ts index 9c72f370..1e283707 100644 --- a/src/extensions/markdown/Link/paste-plugin.ts +++ b/src/extensions/markdown/Link/paste-plugin.ts @@ -1,4 +1,4 @@ -import {Plugin, TextSelection} from 'prosemirror-state'; +import {Plugin, TextSelection, type Transaction} from 'prosemirror-state'; import type {ExtensionDeps, Parser} from '../../../core'; import {isNodeSelection, isTextSelection} from '../../../utils/selection'; @@ -14,28 +14,48 @@ export function linkPasteEnhance({markupParser: parser}: ExtensionDeps) { paste(view, e): boolean { const {state, dispatch} = view; const sel = state.selection; + let tr: Transaction | null = null; + if ( isTextSelection(sel) || (isNodeSelection(sel) && sel.node.type === imageType(state.schema)) ) { const {$from, $to} = sel; - if ($from.pos !== $to.pos && $from.sameParent($to)) { + if ($from.pos === $to.pos) { const url = getUrl(e.clipboardData, parser); if (url) { - const tr = state.tr.addMark( + const linkMarkType = linkType(state.schema); + tr = state.tr.replaceSelectionWith( + state.schema.text(url, [ + ...$from + .marks() + .filter((mark) => mark.type !== linkMarkType), + linkMarkType.create({[LinkAttr.Href]: url}), + ]), + false, + ); + } + } else if ($from.sameParent($to)) { + const url = getUrl(e.clipboardData, parser); + if (url) { + tr = state.tr.addMark( $from.pos, $to.pos, linkType(state.schema).create({ [LinkAttr.Href]: url, }), ); - dispatch(tr.setSelection(TextSelection.create(tr.doc, $to.pos))); - e.preventDefault(); - return true; + tr.setSelection(TextSelection.create(tr.doc, $to.pos)); } } } + if (tr) { + dispatch(tr.scrollIntoView()); + e.preventDefault(); + return true; + } + return false; }, }, From 15767d7d3b0334126e34149d811ce6b6d62909d2 Mon Sep 17 00:00:00 2001 From: Alexey Okhrimenko Date: Thu, 19 Dec 2024 16:51:03 +0300 Subject: [PATCH 11/19] feat(markup): smart re-indent on paste (#530) --- src/markup/codemirror/create.ts | 41 ++++-- .../smart-reindent/__tests__/index.test.ts | 122 ++++++++++++++++++ .../smart-reindent/__tests__/utils.test.ts | 27 ++++ src/markup/codemirror/smart-reindent/index.ts | 46 +++++++ src/markup/codemirror/smart-reindent/utils.ts | 63 +++++++++ 5 files changed, 287 insertions(+), 12 deletions(-) create mode 100644 src/markup/codemirror/smart-reindent/__tests__/index.test.ts create mode 100644 src/markup/codemirror/smart-reindent/__tests__/utils.test.ts create mode 100644 src/markup/codemirror/smart-reindent/index.ts create mode 100644 src/markup/codemirror/smart-reindent/utils.ts diff --git a/src/markup/codemirror/create.ts b/src/markup/codemirror/create.ts index 8396cf9b..0272afb8 100644 --- a/src/markup/codemirror/create.ts +++ b/src/markup/codemirror/create.ts @@ -46,6 +46,7 @@ import {MarkdownConverter} from './html-to-markdown/converters'; import {PairingCharactersExtension} from './pairing-chars'; import {ReactRendererFacet} from './react-facet'; import {SearchPanelPlugin} from './search-plugin/plugin'; +import {smartReindent} from './smart-reindent'; import {type YfmLangOptions, yfmLang} from './yfm'; export type {YfmLangOptions}; @@ -162,12 +163,17 @@ export function createCodemirror(params: CreateCodemirrorParams) { paste(event, editor) { if (!event.clipboardData) return; + const {from} = editor.state.selection.main; + const line = editor.state.doc.lineAt(from); + const currentLine = line.text; + // if clipboard contains YFM content - avoid any meddling with pasted content // since text/yfm will contain valid markdown const yfmContent = event.clipboardData.getData(DataTransferType.Yfm); if (yfmContent) { event.preventDefault(); - editor.dispatch(editor.state.replaceSelection(yfmContent)); + const reindentedYfmContent = smartReindent(yfmContent, currentLine); + editor.dispatch(editor.state.replaceSelection(reindentedYfmContent)); return; } @@ -195,7 +201,11 @@ export function createCodemirror(params: CreateCodemirrorParams) { if (parsedMarkdownMarkup !== undefined) { event.preventDefault(); - editor.dispatch(editor.state.replaceSelection(parsedMarkdownMarkup)); + const reindentedHtmlContent = smartReindent( + parsedMarkdownMarkup, + currentLine, + ); + editor.dispatch(editor.state.replaceSelection(reindentedHtmlContent)); return; } } @@ -206,19 +216,26 @@ export function createCodemirror(params: CreateCodemirrorParams) { event.clipboardData.getData(DataTransferType.Text) ?? '', ) || {}; - if (!imageUrl) { - return; + if (imageUrl) { + event.preventDefault(); + + insertImages([ + { + url: imageUrl, + alt: title, + title, + }, + ])(editor); } + } + // Reindenting pasted plain text + const pastedText = event.clipboardData.getData(DataTransferType.Text); + const reindentedText = smartReindent(pastedText, currentLine); + // but only if there is a need for reindentation + if (pastedText !== reindentedText) { + editor.dispatch(editor.state.replaceSelection(reindentedText)); event.preventDefault(); - - insertImages([ - { - url: imageUrl, - alt: title, - title, - }, - ])(editor); } }, }), diff --git a/src/markup/codemirror/smart-reindent/__tests__/index.test.ts b/src/markup/codemirror/smart-reindent/__tests__/index.test.ts new file mode 100644 index 00000000..3c1fdb02 --- /dev/null +++ b/src/markup/codemirror/smart-reindent/__tests__/index.test.ts @@ -0,0 +1,122 @@ +import {smartReindent} from '../index'; + +describe('smartReindent', () => { + // Basic functionality + it('should preserve pasted text when current line is empty', () => { + const pastedText = 'First line\nSecond line'; + const currentLine = ''; + + expect(smartReindent(pastedText, currentLine)).toBe(pastedText); + }); + + it('should preserve pasted text when current line has no markers', () => { + const pastedText = 'First line\nSecond line'; + const currentLine = 'Just plain text'; + + expect(smartReindent(pastedText, currentLine)).toBe(pastedText); + }); + + // List markers + it('should reindent with numeric list markers', () => { + const pastedText = 'First item\nSecond item\nThird item'; + const currentLine = '1. List item'; + + expect(smartReindent(pastedText, currentLine)).toBe( + 'First item\n Second item\n Third item', + ); + }); + + it('should reindent with dash list markers', () => { + const pastedText = 'First item\nSecond item'; + const currentLine = '- List item'; + + expect(smartReindent(pastedText, currentLine)).toBe('First item\n Second item'); + }); + + it('should reindent with asterisk list markers', () => { + const pastedText = 'First item\nSecond item'; + const currentLine = '* List item'; + + expect(smartReindent(pastedText, currentLine)).toBe('First item\n Second item'); + }); + + it('should reindent with plus list markers', () => { + const pastedText = 'First item\nSecond item'; + const currentLine = '+ List item'; + + expect(smartReindent(pastedText, currentLine)).toBe('First item\n Second item'); + }); + + // Edge cases + it('should handle multi-digit numeric markers correctly', () => { + const pastedText = 'First item\nSecond item'; + const currentLine = '123. List item'; + + expect(smartReindent(pastedText, currentLine)).toBe('First item\n Second item'); + }); + + it('should preserve empty lines with indentation', () => { + const pastedText = 'First item\n\nThird item'; + const currentLine = '- List item'; + + expect(smartReindent(pastedText, currentLine)).toBe('First item\n \n Third item'); + }); + + it('should handle multiple markers correctly', () => { + const pastedText = 'First item\nSecond item'; + const currentLine = ' - Nested list item'; + + expect(smartReindent(pastedText, currentLine)).toBe('First item\n Second item'); + }); + + it('should handle single-line paste correctly', () => { + const pastedText = 'Single line'; + const currentLine = '- List item'; + + expect(smartReindent(pastedText, currentLine)).toBe('Single line'); + }); + + it('should handle windows-style line endings', () => { + const pastedText = 'First item\r\nSecond item'; + const currentLine = '- List item'; + + expect(smartReindent(pastedText, currentLine)).toBe('First item\r\n Second item'); + }); + + // Block quotes + it('should reindent with blockquote markers', () => { + const pastedText = 'First quote\nSecond quote'; + const currentLine = '> Quoted text'; + + expect(smartReindent(pastedText, currentLine)).toBe('First quote\n> Second quote'); + }); + + it('should handle nested blockquotes', () => { + const pastedText = 'First quote\nSecond quote'; + const currentLine = '> > Nested quote'; + + expect(smartReindent(pastedText, currentLine)).toBe('First quote\n> > Second quote'); + }); + + // Spaces and indentation + it('should handle double space indentation', () => { + const pastedText = 'First line\nSecond line'; + const currentLine = ' Indented text'; + + expect(smartReindent(pastedText, currentLine)).toBe('First line\n Second line'); + }); + + it('should handle code block indentation (4 spaces)', () => { + const pastedText = 'var x = 1;\nvar y = 2;'; + const currentLine = ' Code block'; + + expect(smartReindent(pastedText, currentLine)).toBe('var x = 1;\n var y = 2;'); + }); + + it('should handle mixed markers correctly', () => { + const pastedText = 'First line\nSecond line'; + const currentLine = ' > - Nested quote with list'; + + expect(smartReindent(pastedText, currentLine)).toBe('First line\n > Second line'); + }); +}); diff --git a/src/markup/codemirror/smart-reindent/__tests__/utils.test.ts b/src/markup/codemirror/smart-reindent/__tests__/utils.test.ts new file mode 100644 index 00000000..38eb102c --- /dev/null +++ b/src/markup/codemirror/smart-reindent/__tests__/utils.test.ts @@ -0,0 +1,27 @@ +import {parseMarkers} from '../utils'; + +describe('parseMarkers', () => { + it('should parse list markers correctly', () => { + expect(parseMarkers('* list')).toEqual(['* ']); + expect(parseMarkers('- list')).toEqual(['- ']); + expect(parseMarkers('+ list')).toEqual(['+ ']); + expect(parseMarkers(' * list')).toEqual([' ', ' ', '* ']); + expect(parseMarkers(' * list')).toEqual([' ', '* ']); + }); + + it('should parse blockquote markers correctly', () => { + expect(parseMarkers('> quote')).toEqual(['> ']); + expect(parseMarkers(' > quote')).toEqual([' ', ' ', '> ']); + }); + + it('should parse indentation correctly', () => { + expect(parseMarkers(' text')).toEqual([' ', ' ']); + expect(parseMarkers(' text')).toEqual([' ']); + }); + + it('should handle empty or invalid input', () => { + expect(parseMarkers('')).toEqual([]); + expect(parseMarkers('text')).toEqual([]); + expect(parseMarkers(' text')).toEqual([' ']); + }); +}); diff --git a/src/markup/codemirror/smart-reindent/index.ts b/src/markup/codemirror/smart-reindent/index.ts new file mode 100644 index 00000000..af02f4f5 --- /dev/null +++ b/src/markup/codemirror/smart-reindent/index.ts @@ -0,0 +1,46 @@ +import {parseMarkers} from './utils'; + +/** + * Reindents pasted text based on the current line's markers + */ +export function smartReindent(pastedText: string, currentLineText: string): string { + // If current line is empty, return pasted text as is + if (currentLineText.length === 0) { + return pastedText; + } + + // Get markers from current line + const markers = parseMarkers(currentLineText); + + // If no markers found, return pasted text as is + if (markers.length === 0) { + return pastedText; + } + + // Create indentation for subsequent lines by replacing list markers with spaces + const subsequentIndent = markers + .map((marker) => { + if (marker.match(/^\d{1,6}\. |-|\*|\+/)) { + return ' '.repeat(marker.length); + } + return marker; + }) + .join(''); + + // Split and process the pasted text + const lines = pastedText.split('\n'); + + const reindentedText = lines + .map((line, index) => { + // First line doesn't need indentation + if (index === 0) { + return line; + } + + // Add indentation to all subsequent lines, including empty ones + return subsequentIndent + line; + }) + .join('\n'); + + return reindentedText; +} diff --git a/src/markup/codemirror/smart-reindent/utils.ts b/src/markup/codemirror/smart-reindent/utils.ts new file mode 100644 index 00000000..3353e156 --- /dev/null +++ b/src/markup/codemirror/smart-reindent/utils.ts @@ -0,0 +1,63 @@ +/** + * Parses markdown-style markers from the start of a line + * Returns an array of markers found: + * - ' ' for indentation + * - '> ' for blockquotes + * - '* ' or '- ' for list items + * - '1. ' for numbered lists + * + * Example inputs: + * " * list" -> [' ', '* '] + * "> quoted" -> ['> '] + * " nested" -> [' ', ' '] + * "1. list" -> ['1. '] + */ +export function parseMarkers(text: string): string[] { + const markers: string[] = []; + let pos = 0; + + while (pos < text.length) { + // Handle code block (4 spaces) + if ( + pos + 3 < text.length && + text[pos] === ' ' && + text[pos + 1] === ' ' && + text[pos + 2] === ' ' && + text[pos + 3] === ' ' + ) { + markers.push(' '); + pos += 4; + continue; + } + + // Handle numbered lists (1-6 digits followed by dot and space) + if (/^\d{1,6}\. /.test(text.slice(pos))) { + const match = text.slice(pos).match(/^(\d{1,6}\. )/); + if (match) { + markers.push(match[1]); + pos += match[1].length; + continue; + } + } + + // Handle block quotes and list markers + if (text[pos] === '>' || text[pos] === '-' || text[pos] === '*' || text[pos] === '+') { + if (pos + 1 < text.length && text[pos + 1] === ' ') { + markers.push(text[pos] + ' '); + pos += 2; + continue; + } + } + + // Handle single space (last priority) + if (text[pos] === ' ') { + markers.push(' '); + pos += 1; + continue; + } + + break; + } + + return markers; +} From 3ebf14fd580ce29dc0133715cd2cb6bb6ea4ca8a Mon Sep 17 00:00:00 2001 From: Sergey Makhnatkin Date: Thu, 19 Dec 2024 16:16:21 +0100 Subject: [PATCH 12/19] feat(toolbars): restructured toolbar configuration and presets (#509) --- demo/components/Playground.tsx | 104 ++- demo/stories/presets/Preset.tsx | 4 + demo/stories/presets/Presets.stories.tsx | 62 ++ src/bundle/MarkdownEditorView.tsx | 62 +- src/bundle/config/action-names.ts | 77 ++- src/bundle/config/index.ts | 3 + src/bundle/config/markup.tsx | 3 + src/bundle/config/wysiwyg.ts | 3 + src/bundle/toolbar/utils.ts | 136 ++++ src/i18n/menubar/en.json | 1 + src/i18n/menubar/ru.json | 1 + src/modules/toolbars/constants.ts | 14 + src/modules/toolbars/items.tsx | 813 +++++++++++++++++++++++ src/modules/toolbars/presets.ts | 614 +++++++++++++++++ src/modules/toolbars/types.ts | 86 +++ src/toolbar/types.ts | 8 + 16 files changed, 1893 insertions(+), 98 deletions(-) create mode 100644 src/bundle/toolbar/utils.ts create mode 100644 src/modules/toolbars/constants.ts create mode 100644 src/modules/toolbars/items.tsx create mode 100644 src/modules/toolbars/presets.ts create mode 100644 src/modules/toolbars/types.ts diff --git a/demo/components/Playground.tsx b/demo/components/Playground.tsx index b6c5708f..19815475 100644 --- a/demo/components/Playground.tsx +++ b/demo/components/Playground.tsx @@ -18,7 +18,6 @@ import { type UseMarkdownEditorProps, WysiwygPlaceholderOptions, logger, - markupToolbarConfigs, useMarkdownEditor, wysiwygToolbarConfigs, } from '../../src'; @@ -29,8 +28,8 @@ import {Math} from '../../src/extensions/additional/Math'; import {Mermaid} from '../../src/extensions/additional/Mermaid'; import {YfmHtmlBlock} from '../../src/extensions/additional/YfmHtmlBlock'; import {getSanitizeYfmHtmlBlock} from '../../src/extensions/additional/YfmHtmlBlock/utils'; -import {cloneDeep} from '../../src/lodash'; import type {CodeEditor} from '../../src/markup'; +import {ToolbarsPreset} from '../../src/modules/toolbars/types'; import {VERSION} from '../../src/version'; import {getPlugins} from '../defaults/md-plugins'; import useYfmHtmlBlockStyles from '../hooks/useYfmHtmlBlockStyles'; @@ -52,19 +51,6 @@ const fileUploadHandler: FileUploadHandler = async (file) => { return {url: URL.createObjectURL(file)}; }; -const mToolbarConfig = [ - ...markupToolbarConfigs.mToolbarConfig, - [markupToolbarConfigs.mMermaidButton, markupToolbarConfigs.mYfmHtmlBlockButton], -]; -mToolbarConfig[2].push(markupToolbarConfigs.mMathListItem); - -const wToolbarConfig = cloneDeep(wysiwygToolbarConfigs.wToolbarConfig); -wToolbarConfig[2].push(wysiwygToolbarConfigs.wMathListItem); -wToolbarConfig.push([ - wysiwygToolbarConfigs.wMermaidItemData, - wysiwygToolbarConfigs.wYfmHtmlBlockItemData, -]); - const wCommandMenuConfig = wysiwygToolbarConfigs.wCommandMenuConfig.concat( wysiwygToolbarConfigs.wMathInlineItemData, wysiwygToolbarConfigs.wMathBlockItemData, @@ -92,6 +78,7 @@ export type PlaygroundProps = { escapeConfig?: EscapeConfig; wysiwygCommandMenuConfig?: wysiwygToolbarConfigs.WToolbarItemData[]; markupToolbarConfig?: ToolbarGroupData[]; + toolbarsPreset?: ToolbarsPreset; onChangeEditorType?: (mode: MarkdownEditorMode) => void; onChangeSplitModeEnabled?: (splitModeEnabled: boolean) => void; directiveSyntax?: DirectiveSyntaxValue; @@ -137,6 +124,7 @@ export const Playground = React.memo((props) => { height, extraExtensions, extensionOptions, + toolbarsPreset, wysiwygToolbarConfig, wysiwygCommandMenuConfig, markupConfigExtensions, @@ -175,6 +163,47 @@ export const Playground = React.memo((props) => { const mdEditor = useMarkdownEditor( { + preset: 'full', + wysiwygConfig: { + escapeConfig, + placeholderOptions: placeholderOptions, + extensions: (builder) => { + builder + .use(Math, { + loadRuntimeScript: () => { + import( + /* webpackChunkName: "latex-runtime" */ '@diplodoc/latex-extension/runtime' + ); + import( + // @ts-expect-error // no types for styles + /* webpackChunkName: "latex-styles" */ '@diplodoc/latex-extension/runtime/styles' + ); + }, + }) + .use(Mermaid, { + loadRuntimeScript: () => { + import( + /* webpackChunkName: "mermaid-runtime" */ '@diplodoc/mermaid-extension/runtime' + ); + }, + }) + .use(FoldingHeading) + .use(YfmHtmlBlock, { + useConfig: useYfmHtmlBlockStyles, + sanitize: getSanitizeYfmHtmlBlock({options: defaultOptions}), + head: ` + + ((props) => { initialSplitModeEnabled: initialSplitModeEnabled, initialToolbarVisible: true, splitMode: splitModeOrientation, - escapeConfig: escapeConfig, needToSetDimensionsForUploadedImages, renderPreview: renderPreviewDefined ? renderPreview : undefined, fileUploadHandler, - wysiwygConfig: { - placeholderOptions: placeholderOptions, - }, experimental: { ...experimental, directiveSyntax, @@ -209,42 +234,6 @@ export const Playground = React.memo((props) => { extensions: markupConfigExtensions, parseInsertedUrlAsImage, }, - extraExtensions: (builder) => { - builder - .use(Math, { - loadRuntimeScript: () => { - import( - /* webpackChunkName: "latex-runtime" */ '@diplodoc/latex-extension/runtime' - ); - import( - // @ts-expect-error // no types for styles - /* webpackChunkName: "latex-styles" */ '@diplodoc/latex-extension/runtime/styles' - ); - }, - }) - .use(Mermaid, { - loadRuntimeScript: () => { - import( - /* webpackChunkName: "mermaid-runtime" */ '@diplodoc/mermaid-extension/runtime' - ); - }, - }) - .use(FoldingHeading) - .use(YfmHtmlBlock, { - useConfig: useYfmHtmlBlockStyles, - sanitize: getSanitizeYfmHtmlBlock({options: defaultOptions}), - head: ` - - ((props) => { toaster={toaster} className={b('editor-view')} stickyToolbar={Boolean(stickyToolbar)} - wysiwygToolbarConfig={wysiwygToolbarConfig ?? wToolbarConfig} - markupToolbarConfig={markupToolbarConfig ?? mToolbarConfig} + toolbarsPreset={toolbarsPreset} + wysiwygToolbarConfig={wysiwygToolbarConfig} + markupToolbarConfig={markupToolbarConfig} settingsVisible={settingsVisible} editor={mdEditor} enableSubmitInPreview={enableSubmitInPreview} diff --git a/demo/stories/presets/Preset.tsx b/demo/stories/presets/Preset.tsx index 58fc5c14..57477476 100644 --- a/demo/stories/presets/Preset.tsx +++ b/demo/stories/presets/Preset.tsx @@ -11,6 +11,7 @@ import { logger, useMarkdownEditor, } from '../../../src'; +import {ToolbarsPreset} from '../../../src/modules/toolbars/types'; import type {FileUploadHandler} from '../../../src/utils/upload'; import {VERSION} from '../../../src/version'; // --- @@ -41,6 +42,7 @@ export type PresetDemoProps = { splitModeOrientation?: 'horizontal' | 'vertical' | false; stickyToolbar?: boolean; height?: CSSProperties['height']; + toolbarsPreset?: ToolbarsPreset; }; logger.setLogger({ @@ -60,6 +62,7 @@ export const Preset = React.memo((props) => { splitModeOrientation, stickyToolbar, height, + toolbarsPreset, } = props; const [editorMode, setEditorMode] = React.useState('wysiwyg'); const [mdRaw, setMdRaw] = React.useState(''); @@ -130,6 +133,7 @@ export const Preset = React.memo((props) => {
= { @@ -22,6 +40,50 @@ export const Full: StoryObj = { args: {preset: 'full'}, }; +export const Custom: StoryObj = { + args: { + toolbarsPreset: { + items: { + [Action.undo]: { + view: undoItemView, + wysiwyg: undoItemWysiwyg, + markup: undoItemMarkup, + }, + [Action.redo]: { + view: redoItemView, + wysiwyg: redoItemWysiwyg, + markup: redoItemMarkup, + }, + [Action.bold]: { + view: boldItemView, + wysiwyg: boldItemWysiwyg, + }, + [Action.italic]: { + view: italicItemView, + markup: italicItemMarkup, + }, + [Action.colorify]: { + view: colorifyItemView, + wysiwyg: colorifyItemWysiwyg, + markup: colorifyItemMarkup, + }, + }, + orders: { + [Toolbar.wysiwygMain]: [ + [Action.colorify], + [Action.bold], + [Action.undo, Action.redo], + ], + [Toolbar.markupMain]: [ + [Action.colorify], + [Action.italic], + [Action.undo, Action.redo], + ], + }, + }, + }, +}; + export default { component, title: 'Extensions / Presets', diff --git a/src/bundle/MarkdownEditorView.tsx b/src/bundle/MarkdownEditorView.tsx index 437cd2de..d0fe9d6f 100644 --- a/src/bundle/MarkdownEditorView.tsx +++ b/src/bundle/MarkdownEditorView.tsx @@ -7,6 +7,7 @@ import {useEnsuredForwardedRef, useKey, useUpdate} from 'react-use'; import {ClassNameProps, cn} from '../classname'; import {i18n} from '../i18n/bundle'; import {logger} from '../logger'; +import type {ToolbarsPreset} from '../modules/toolbars/types'; import {ToasterContext, useBooleanState, useSticky} from '../react-utils'; import {isMac} from '../utils'; @@ -15,19 +16,11 @@ import {HorizontalDrag} from './HorizontalDrag'; import {MarkupEditorView} from './MarkupEditorView'; import {SplitModeView} from './SplitModeView'; import {WysiwygEditorView} from './WysiwygEditorView'; -import { - MToolbarData, - MToolbarItemData, - WToolbarData, - WToolbarItemData, - mHiddenDataByPreset, - mToolbarConfigByPreset, - wHiddenDataByPreset, - wToolbarConfigByPreset, -} from './config'; +import {MToolbarData, MToolbarItemData, WToolbarData, WToolbarItemData} from './config'; import {useMarkdownEditorContext} from './context'; import {EditorSettings, EditorSettingsProps} from './settings'; import {stickyCn} from './sticky'; +import {getToolbarsConfigs} from './toolbar/utils'; import type {MarkdownEditorMode} from './types'; import '../styles/styles.scss'; @@ -39,9 +32,22 @@ const b = cnEditorComponent; export type MarkdownEditorViewProps = ClassNameProps & { editor?: Editor; autofocus?: boolean; + toolbarsPreset?: ToolbarsPreset; + /** + * @deprecated use `toolbarsPreset` instead + */ markupToolbarConfig?: MToolbarData; + /** + * @deprecated use `toolbarsPreset` instead + */ wysiwygToolbarConfig?: WToolbarData; + /** + * @deprecated use `toolbarsPreset` instead + */ markupHiddenActionsConfig?: MToolbarItemData[]; + /** + * @deprecated use `toolbarsPreset` instead + */ wysiwygHiddenActionsConfig?: WToolbarItemData[]; /** @default true */ settingsVisible?: boolean; @@ -73,16 +79,44 @@ export const MarkdownEditorView = React.forwardRef + getToolbarsConfigs({ + toolbarsPreset, + props: { + wysiwygToolbarConfig: initialWysiwygToolbarConfig, + markupToolbarConfig: initialMarkupToolbarConfig, + wysiwygHiddenActionsConfig: initialWysiwygHiddenActionsConfig, + markupHiddenActionsConfig: initialMarkupHiddenActionsConfig, + }, + preset: editor.preset, + }), + [ + toolbarsPreset, + initialWysiwygToolbarConfig, + initialMarkupToolbarConfig, + initialWysiwygHiddenActionsConfig, + initialMarkupHiddenActionsConfig, + editor.preset, + ], + ); + const rerender = useUpdate(); React.useLayoutEffect(() => { editor.on('rerender', rerender); diff --git a/src/bundle/config/action-names.ts b/src/bundle/config/action-names.ts index 29e127ae..a4a5d2e1 100644 --- a/src/bundle/config/action-names.ts +++ b/src/bundle/config/action-names.ts @@ -1,44 +1,67 @@ const names = [ - 'undo', - 'redo', + 'anchor', 'bold', - 'italic', - 'underline', - 'strike', - 'mono', - 'mark', - 'paragraph', + 'bulletList', + 'checkbox', + /** @deprecated use codeBlock */ + 'code_block', + 'codeBlock', + /** @deprecated use codeInline */ + 'code_inline', + 'codeInline', + 'colorify', + 'emoji', + 'file', + 'filePopup', + 'gpt', 'heading1', 'heading2', 'heading3', 'heading4', 'heading5', 'heading6', - 'bulletList', - 'orderedList', + /** @deprecated use horizontalRule */ + 'horizontalrule', + 'horizontalRule', + 'image', + 'imagePopup', + 'italic', 'liftListItem', - 'sinkListItem', - 'checkbox', 'link', + 'mark', + /** @deprecated use mathBlock */ + 'math_block', + 'mathBlock', + /** @deprecated use mathInline */ + 'math_inline', + 'mathInline', + 'mermaid', + 'mono', + 'orderedList', + 'paragraph', 'quote', - 'yfm_cut', - 'yfm_note', + 'redo', + 'sinkListItem', + 'strike', + 'table', + 'tabs', + 'underline', + 'undo', + /** @deprecated use block */ 'yfm_block', + 'block', + /** @deprecated use cut */ + 'yfm_cut', + 'cut', + /** @deprecated use htmlBlock */ 'yfm_html_block', + 'htmlBlock', + /** @deprecated use layout */ 'yfm_layout', - 'table', - 'code_inline', - 'code_block', - 'image', - 'horizontalrule', - 'emoji', - 'file', - 'anchor', - 'math_inline', - 'math_block', - 'tabs', - 'mermaid', - 'gpt', + 'layout', + /** @deprecated use note */ + 'yfm_note', + 'note', ] as const; type ItemsType = L extends readonly (infer T)[] ? T : never; diff --git a/src/bundle/config/index.ts b/src/bundle/config/index.ts index aa50e1d1..5b7fbe26 100644 --- a/src/bundle/config/index.ts +++ b/src/bundle/config/index.ts @@ -1,2 +1,5 @@ +/** + * @deprecated This file is deprecated. Use ToolbarsPreset instead. + */ export * from './wysiwyg'; export * from './markup'; diff --git a/src/bundle/config/markup.tsx b/src/bundle/config/markup.tsx index 38dcbb17..b6835be9 100644 --- a/src/bundle/config/markup.tsx +++ b/src/bundle/config/markup.tsx @@ -1,3 +1,6 @@ +/** + * @deprecated This file is deprecated. Use ToolbarsPreset instead. + */ import React from 'react'; import {i18n} from '../../i18n/menubar'; diff --git a/src/bundle/config/wysiwyg.ts b/src/bundle/config/wysiwyg.ts index bc761b6a..db9d1a38 100644 --- a/src/bundle/config/wysiwyg.ts +++ b/src/bundle/config/wysiwyg.ts @@ -1,3 +1,6 @@ +/** + * @deprecated This file is deprecated. Use ToolbarsPreset instead. + */ import {ActionStorage} from 'src/core'; import {headingType, pType} from '../../extensions'; diff --git a/src/bundle/toolbar/utils.ts b/src/bundle/toolbar/utils.ts new file mode 100644 index 00000000..94447bc1 --- /dev/null +++ b/src/bundle/toolbar/utils.ts @@ -0,0 +1,136 @@ +import {ToolbarName} from '../../modules/toolbars/constants'; +import {commonmark, defaultPreset, full, yfm, zero} from '../../modules/toolbars/presets'; +import type { + ToolbarItem, + ToolbarItemMarkup, + ToolbarItemWysiwyg, + ToolbarsPreset, +} from '../../modules/toolbars/types'; +import type {MToolbarData, MToolbarItemData, WToolbarData, WToolbarItemData} from '../../toolbar'; +import {ToolbarDataType, ToolbarIconData} from '../../toolbar'; +import type {MarkdownEditorViewProps} from '../MarkdownEditorView'; +import {MarkdownEditorPreset} from '../types'; + +const defaultPresets: Record = { + zero, + commonmark, + default: defaultPreset, + yfm, + full, +}; + +interface TransformedItem { + type: ToolbarDataType; + id: string; + title?: string | (() => string); + hint?: string | (() => string); + icon?: ToolbarIconData; + hotkey?: string; + withArrow?: boolean; + wysiwyg?: ToolbarItemWysiwyg; + markup?: ToolbarItemMarkup; +} + +const transformItem = ( + type: 'wysiwyg' | 'markup', + 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; + } + + const isListButton = item.view.type === ToolbarDataType.ListButton; + + return { + type: item.view.type ?? ToolbarDataType.SingleButton, + id, + title: item.view.title, + hint: item.view.hint, + icon: item.view.icon, + hotkey: item.view.hotkey, + ...(isListButton && {withArrow: (item.view as any).withArrow}), + ...(type === 'wysiwyg' && item.wysiwyg && {...item.wysiwyg}), + ...(type === 'markup' && item.markup && {...item.markup}), + }; +}; + +export const createConfig = ( + editorType: 'wysiwyg' | 'markup', + toolbarPreset: ToolbarsPreset | MarkdownEditorPreset, + toolbarName: string, +): T => { + const preset = + typeof toolbarPreset === 'string' + ? defaultPresets[toolbarPreset] || defaultPresets.default + : toolbarPreset; + const orders = preset.orders[toolbarName] ?? [[]]; + const {items} = preset; + + const toolbarData = orders.map((group) => + group.map((action) => { + return typeof action === 'string' + ? transformItem(editorType, items[action], action) + : { + ...transformItem(editorType, items[action.id], action.id), + data: action.items.map((id) => transformItem(editorType, items[id], id)), + }; + }), + ); + + return toolbarData as T; +}; + +const flattenPreset = (config: T) => { + // TODO: @makhnatkin add logic for flatten + return (config[0] ?? []) as unknown as T extends WToolbarData + ? WToolbarItemData[] + : MToolbarItemData[]; +}; + +interface GetToolbarsConfigsArgs { + toolbarsPreset?: ToolbarsPreset; + props: Pick< + MarkdownEditorViewProps, + | 'markupToolbarConfig' + | 'wysiwygToolbarConfig' + | 'wysiwygHiddenActionsConfig' + | 'markupHiddenActionsConfig' + >; + preset: MarkdownEditorPreset; +} +export const getToolbarsConfigs = ({toolbarsPreset, props, preset}: GetToolbarsConfigsArgs) => { + const wysiwygToolbarConfig = toolbarsPreset + ? createConfig('wysiwyg', toolbarsPreset, ToolbarName.wysiwygMain) + : props.wysiwygToolbarConfig ?? + createConfig('wysiwyg', preset, ToolbarName.wysiwygMain); + + const markupToolbarConfig = toolbarsPreset + ? createConfig('markup', toolbarsPreset, ToolbarName.markupMain) + : props.markupToolbarConfig ?? + createConfig('markup', preset, ToolbarName.markupMain); + + const wysiwygHiddenActionsConfig = toolbarsPreset + ? flattenPreset( + createConfig('wysiwyg', toolbarsPreset, ToolbarName.wysiwygHidden), + ) + : props.wysiwygHiddenActionsConfig ?? + flattenPreset(createConfig('wysiwyg', preset, ToolbarName.wysiwygHidden)); + + const markupHiddenActionsConfig = toolbarsPreset + ? flattenPreset( + createConfig('markup', toolbarsPreset, ToolbarName.markupHidden), + ) + : props.markupHiddenActionsConfig ?? + flattenPreset(createConfig('markup', preset, ToolbarName.markupHidden)); + + return { + wysiwygToolbarConfig, + markupToolbarConfig, + wysiwygHiddenActionsConfig, + markupHiddenActionsConfig, + }; +}; diff --git a/src/i18n/menubar/en.json b/src/i18n/menubar/en.json index d007825c..7f15a7f5 100644 --- a/src/i18n/menubar/en.json +++ b/src/i18n/menubar/en.json @@ -44,6 +44,7 @@ "mermaid": "Mermaid", "mono": "Monospace", "more_action": "More action", + "move_list": "Move list item", "note": "Note", "olist": "Ordered list", "quote": "Quote", diff --git a/src/i18n/menubar/ru.json b/src/i18n/menubar/ru.json index 10d123d9..378bfac0 100644 --- a/src/i18n/menubar/ru.json +++ b/src/i18n/menubar/ru.json @@ -44,6 +44,7 @@ "mermaid": "Mermaid", "mono": "Моноширинный", "more_action": "Другие действия", + "move_list": "Переместить элемент списка", "note": "Примечание", "olist": "Нумерованный список", "quote": "Цитата", diff --git a/src/modules/toolbars/constants.ts b/src/modules/toolbars/constants.ts new file mode 100644 index 00000000..1b7cd315 --- /dev/null +++ b/src/modules/toolbars/constants.ts @@ -0,0 +1,14 @@ +export enum ListName { + heading = 'heading', + lists = 'lists', + code = 'code', +} + +export enum ToolbarName { + markupHidden = 'markupHidden', + markupMain = 'markupMain', + wysiwygHidden = 'wysiwygHidden', + wysiwygMain = 'wysiwygMain', + wysiwygSelection = 'wysiwygSelection', + wysiwygSlash = 'wysiwygSlash', +} diff --git a/src/modules/toolbars/items.tsx b/src/modules/toolbars/items.tsx new file mode 100644 index 00000000..80331f1d --- /dev/null +++ b/src/modules/toolbars/items.tsx @@ -0,0 +1,813 @@ +import React from 'react'; + +import {icons} from '../../bundle/config/icons'; +import {MToolbarColors} from '../../bundle/toolbar/markup/MToolbarColors'; +import {MToolbarFilePopup} from '../../bundle/toolbar/markup/MToolbarFilePopup'; +import {MToolbarImagePopup} from '../../bundle/toolbar/markup/MToolbarImagePopup'; +import {WToolbarColors} from '../../bundle/toolbar/wysiwyg/WToolbarColors'; +import {WToolbarTextSelect} from '../../bundle/toolbar/wysiwyg/WToolbarTextSelect'; +import {headingType, pType} from '../../extensions'; +import {gptHotKeys} from '../../extensions/additional/GPT/constants'; +import {i18n as i18nHint} from '../../i18n/hints'; +import {i18n} from '../../i18n/menubar'; +import { + insertHRule, + insertLink, + insertMermaidDiagram, + insertYfmHtmlBlock, + insertYfmTable, + insertYfmTabs, + liftListItem as liftListItemCommand, + redo, + redoDepth, + sinkListItem as sinkListItemCommand, + toBulletList, + toH1, + toH2, + toH3, + toH4, + toH5, + toH6, + toOrderedList, + toggleBold, + toggleItalic, + toggleMarked, + toggleMonospace, + toggleStrikethrough, + toggleUnderline, + undo, + undoDepth, + wrapToBlockquote, + wrapToCheckbox, + wrapToCodeBlock, + wrapToInlineCode, + wrapToMathBlock, + wrapToMathInline, + wrapToYfmCut, + wrapToYfmNote, +} from '../../markup/commands'; +import {Action as A, formatter as f} from '../../shortcuts'; +import {ToolbarDataType} from '../../toolbar'; + +import {ToolbarItemMarkup, ToolbarItemView, ToolbarItemWysiwyg} from './types'; + +const noop = () => {}; +const inactive = () => false; +const enable = () => true; +const disable = () => false; + +// ---- Undo ---- +export const undoItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'undo'), + icon: icons.undo, + hotkey: f.toView(A.Undo), +}; +export const undoItemWysiwyg: ToolbarItemWysiwyg = { + hintWhenDisabled: false, + exec: (e) => e.actions.undo.run(), + isActive: (e) => e.actions.undo.isActive(), + isEnable: (e) => e.actions.undo.isEnable(), +}; +export const undoItemMarkup: ToolbarItemMarkup = { + hintWhenDisabled: false, + exec: (e) => undo(e.cm), + isActive: inactive, + isEnable: (e) => undoDepth(e.cm.state) > 0, +}; + +// ---- Redo ---- +export const redoItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'redo'), + icon: icons.redo, + hotkey: f.toView(A.Redo), +}; +export const redoItemWysiwyg: ToolbarItemWysiwyg = { + hintWhenDisabled: false, + exec: (e) => e.actions.redo.run(), + isActive: (e) => e.actions.redo.isActive(), + isEnable: (e) => e.actions.redo.isEnable(), +}; +export const redoItemMarkup: ToolbarItemMarkup = { + hintWhenDisabled: false, + exec: (e) => redo(e.cm), + isActive: inactive, + isEnable: (e) => redoDepth(e.cm.state) > 0, +}; + +// ---- Bold ---- +export const boldItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'bold'), + icon: icons.bold, + hotkey: f.toView(A.Bold), +}; +export const boldItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.bold.run(), + isActive: (e) => e.actions.bold.isActive(), + isEnable: (e) => e.actions.bold.isEnable(), +}; +export const boldItemMarkup: ToolbarItemMarkup = { + exec: (e) => toggleBold(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Italic ---- +export const italicItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'italic'), + icon: icons.italic, + hotkey: f.toView(A.Italic), +}; +export const italicItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.italic.run(), + isActive: (e) => e.actions.italic.isActive(), + isEnable: (e) => e.actions.italic.isEnable(), +}; +export const italicItemMarkup: ToolbarItemMarkup = { + exec: (e) => toggleItalic(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Underline ---- +export const underlineItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'underline'), + icon: icons.underline, + hotkey: f.toView(A.Underline), +}; +export const underlineItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.underline.run(), + isActive: (e) => e.actions.underline.isActive(), + isEnable: (e) => e.actions.underline.isEnable(), +}; +export const underlineItemMarkup: ToolbarItemMarkup = { + exec: (e) => toggleUnderline(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Strikethrough ---- +export const strikethroughItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'strike'), + icon: icons.strikethrough, + hotkey: f.toView(A.Strike), +}; +export const strikethroughItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.strike.run(), + isActive: (e) => e.actions.strike.isActive(), + isEnable: (e) => e.actions.strike.isEnable(), +}; +export const strikethroughItemMarkup: ToolbarItemMarkup = { + exec: (e) => toggleStrikethrough(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Monospace ---- +export const monospaceItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'mono'), + icon: icons.mono, +}; +export const monospaceItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.mono.run(), + isActive: (e) => e.actions.mono.isActive(), + isEnable: (e) => e.actions.mono.isEnable(), +}; +export const monospaceItemMarkup: ToolbarItemMarkup = { + exec: (e) => toggleMonospace(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Marked ---- +export const markedItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'mark'), + icon: icons.mark, +}; +export const markedItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.mark.run(), + isActive: (e) => e.actions.mark.isActive(), + isEnable: (e) => e.actions.mark.isEnable(), +}; +export const markedItemMarkup: ToolbarItemMarkup = { + exec: (e) => toggleMarked(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Checkbox ---- +export const checkboxItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'checkbox'), + icon: icons.checklist, +}; +export const checkboxItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.addCheckbox.run(), + isActive: (e) => e.actions.addCheckbox.isActive(), + isEnable: (e) => e.actions.addCheckbox.isEnable(), +}; +export const checkboxItemMarkup: ToolbarItemMarkup = { + exec: (e) => wrapToCheckbox(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Link ---- +export const linkItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'link'), + icon: icons.link, + hotkey: f.toView(A.Link), +}; +export const linkItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.addLink.run(), + isActive: (e) => e.actions.addLink.isActive(), + isEnable: (e) => e.actions.addLink.isEnable(), +}; +export const linkItemMarkup: ToolbarItemMarkup = { + exec: (e) => insertLink(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Quote ---- +export const quoteItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'quote'), + icon: icons.quote, + hotkey: f.toView(A.Quote), +}; +export const quoteItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.quote.run(), + isActive: (e) => e.actions.quote.isActive(), + isEnable: (e) => e.actions.quote.isEnable(), +}; +export const quoteItemMarkup: ToolbarItemMarkup = { + exec: (e) => wrapToBlockquote(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Cut ---- +export const cutItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'cut'), + icon: icons.cut, + hotkey: f.toView(A.Cut), +}; +export const cutItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.toYfmCut.run(), + isActive: (e) => e.actions.toYfmCut.isActive(), + isEnable: (e) => e.actions.toYfmCut.isEnable(), +}; +export const cutItemMarkup: ToolbarItemMarkup = { + exec: (e) => wrapToYfmCut(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Note ---- +export const noteItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'note'), + icon: icons.note, + hotkey: f.toView(A.Note), +}; +export const noteItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.toYfmNote.run(), + isActive: (e) => e.actions.toYfmNote.isActive(), + isEnable: (e) => e.actions.toYfmNote.isEnable(), +}; +export const noteItemMarkup: ToolbarItemMarkup = { + exec: (e) => wrapToYfmNote(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Table ---- +export const tableItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'table'), + icon: icons.table, +}; +export const tableItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.createYfmTable.run(), + isActive: (e) => e.actions.createYfmTable.isActive(), + isEnable: (e) => e.actions.createYfmTable.isEnable(), +}; +export const tableItemMarkup: ToolbarItemMarkup = { + exec: (e) => insertYfmTable(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Code ---- +export const codeItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'code_inline'), + icon: icons.code, + hotkey: f.toView(A.Code), +}; +export const codeItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.code.run(), + isActive: (e) => e.actions.code.isActive(), + isEnable: (e) => e.actions.code.isEnable(), +}; +export const codeItemMarkup: ToolbarItemMarkup = { + exec: (e) => wrapToInlineCode(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Image ---- +export const imageItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'image'), + icon: icons.image, +}; +export const imagePopupItemView: ToolbarItemView = { + type: ToolbarDataType.ButtonPopup, + title: i18n.bind(null, 'image'), + icon: icons.image, +}; +export const imageItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.addImageWidget.run(), + isActive: (e) => e.actions.addImageWidget.isActive(), + isEnable: (e) => e.actions.addImageWidget.isEnable(), +}; +export const imageItemMarkup: ToolbarItemMarkup = { + exec: noop, + isActive: inactive, + isEnable: enable, + renderPopup: (props) => , +}; + +// ---- File ---- +export const fileItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'file'), + icon: icons.file, +}; +export const filePopupItemView: ToolbarItemView = { + type: ToolbarDataType.ButtonPopup, + title: i18n.bind(null, 'file'), + icon: icons.file, +}; +export const fileItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.addFile.run(), + isActive: (e) => e.actions.addFile.isActive(), + isEnable: (e) => e.actions.addFile.isEnable(), +}; +export const fileItemMarkup: ToolbarItemMarkup = { + exec: noop, + isActive: inactive, + isEnable: enable, + renderPopup: (props) => , +}; + +// ---- Tabs ---- +export const tabsItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'tabs'), + icon: icons.tabs, +}; +export const tabsItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.toYfmTabs.run(), + isActive: (e) => e.actions.toYfmTabs.isActive(), + isEnable: (e) => e.actions.toYfmTabs.isEnable(), +}; +export const tabsItemMarkup: ToolbarItemMarkup = { + exec: (e) => insertYfmTabs(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Math Inline ---- +export const mathInlineItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'math_inline'), + icon: icons.functionInline, + hint: () => `${i18nHint.bind(null, 'math_hint')()} ${i18nHint.bind(null, 'math_hint_katex')()}`, +}; +export const mathInlineItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.addMathInline.run(), + isActive: (e) => e.actions.addMathInline.isActive(), + isEnable: (e) => e.actions.addMathInline.isEnable(), +}; +export const mathInlineItemMarkup: ToolbarItemMarkup = { + exec: (e) => wrapToMathInline(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Math Block ---- +export const mathBlockItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'math_block'), + icon: icons.functionBlock, + hint: () => `${i18nHint.bind(null, 'math_hint')()} ${i18nHint.bind(null, 'math_hint_katex')()}`, +}; +export const mathBlockItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.toMathBlock.run(), + isActive: (e) => e.actions.toMathBlock.isActive(), + isEnable: (e) => e.actions.toMathBlock.isEnable(), +}; +export const mathBlockItemMarkup: ToolbarItemMarkup = { + exec: (e) => wrapToMathBlock(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Yfm Html Block ---- +export const yfmHtmlBlockItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'html'), + icon: icons.html, +}; +export const yfmHtmlBlockItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.createYfmHtmlBlock.run(), + isActive: (e) => e.actions.createYfmHtmlBlock.isActive(), + isEnable: (e) => e.actions.createYfmHtmlBlock.isEnable(), +}; +export const yfmHtmlBlockItemMarkup: ToolbarItemMarkup = { + exec: (e) => insertYfmHtmlBlock(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Mermaid ---- +export const mermaidItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'mermaid'), + icon: icons.mermaid, +}; +export const mermaidItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.createMermaid.run(), + isActive: (e) => e.actions.createMermaid.isActive(), + isEnable: (e) => e.actions.createMermaid.isEnable(), +}; +export const mermaidItemMarkup: ToolbarItemMarkup = { + exec: (e) => insertMermaidDiagram(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Code Block ---- +export const codeBlockItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'codeblock'), + icon: icons.codeBlock, + hotkey: f.toView(A.CodeBlock), +}; +export const codeBlockItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.toCodeBlock.run(), + isActive: (e) => e.actions.toCodeBlock.isActive(), + isEnable: (e) => e.actions.toCodeBlock.isEnable(), +}; +export const codeBlockItemMarkup: ToolbarItemMarkup = { + exec: (e) => wrapToCodeBlock(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Horizontal Rule ---- +export const hruleItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'hrule'), + icon: icons.horizontalRule, +}; +export const hruleItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.hRule.run(), + isActive: (e) => e.actions.hRule.isActive(), + isEnable: (e) => e.actions.hRule.isEnable(), +}; +export const hruleItemMarkup: ToolbarItemMarkup = { + exec: (e) => insertHRule(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Emoji ---- +export const emojiItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'emoji'), + icon: icons.emoji, +}; +export const emojiItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.openEmojiSuggest.run({}), + isActive: (e) => e.actions.openEmojiSuggest.isActive(), + isEnable: (e) => e.actions.openEmojiSuggest.isEnable(), +}; +export const emojiItemMarkup: ToolbarItemMarkup = { + exec: noop, + hintWhenDisabled: i18n.bind(null, 'emoji__hint'), + isActive: inactive, + isEnable: disable, +}; + +// ---- Heading 1 ---- +export const heading1ItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'heading1'), + icon: icons.h1, + hotkey: f.toView(A.Heading1), +}; +export const heading1ItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.toH1.run(), + isActive: (e) => e.actions.toH1.isActive(), + isEnable: (e) => e.actions.toH1.isEnable(), +}; +export const heading1ItemMarkup: ToolbarItemMarkup = { + exec: (e) => toH1(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Heading 2 ---- +export const heading2ItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'heading2'), + icon: icons.h2, + hotkey: f.toView(A.Heading2), +}; +export const heading2ItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.toH2.run(), + isActive: (e) => e.actions.toH2.isActive(), + isEnable: (e) => e.actions.toH2.isEnable(), +}; +export const heading2ItemMarkup: ToolbarItemMarkup = { + exec: (e) => toH2(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Heading 3 ---- +export const heading3ItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'heading3'), + icon: icons.h3, + hotkey: f.toView(A.Heading3), +}; +export const heading3ItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.toH3.run(), + isActive: (e) => e.actions.toH3.isActive(), + isEnable: (e) => e.actions.toH3.isEnable(), +}; +export const heading3ItemMarkup: ToolbarItemMarkup = { + exec: (e) => toH3(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Heading 4 ---- +export const heading4ItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'heading4'), + icon: icons.h4, + hotkey: f.toView(A.Heading4), +}; +export const heading4ItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.toH4.run(), + isActive: (e) => e.actions.toH4.isActive(), + isEnable: (e) => e.actions.toH4.isEnable(), +}; +export const heading4ItemMarkup: ToolbarItemMarkup = { + exec: (e) => toH4(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Heading 5 ---- +export const heading5ItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'heading5'), + icon: icons.h5, + hotkey: f.toView(A.Heading5), +}; +export const heading5ItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.toH5.run(), + isActive: (e) => e.actions.toH5.isActive(), + isEnable: (e) => e.actions.toH5.isEnable(), +}; +export const heading5ItemMarkup: ToolbarItemMarkup = { + exec: (e) => toH5(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Heading 6 ---- +export const heading6ItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'heading6'), + icon: icons.h6, + hotkey: f.toView(A.Heading6), +}; +export const heading6ItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.toH6.run(), + isActive: (e) => e.actions.toH6.isActive(), + isEnable: (e) => e.actions.toH6.isEnable(), +}; +export const heading6ItemMarkup: ToolbarItemMarkup = { + exec: (e) => toH6(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Bullet List ---- +export const bulletListItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'ulist'), + icon: icons.bulletList, + hotkey: f.toView(A.BulletList), +}; +export const bulletListItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.toBulletList.run(), + isActive: (e) => e.actions.toBulletList.isActive(), + isEnable: (e) => e.actions.toBulletList.isEnable(), +}; +export const bulletListItemMarkup: ToolbarItemMarkup = { + exec: (e) => toBulletList(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Ordered List ---- +export const orderedListItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'olist'), + icon: icons.orderedList, + hotkey: f.toView(A.OrderedList), +}; +export const orderedListItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.toOrderedList.run(), + isActive: (e) => e.actions.toOrderedList.isActive(), + isEnable: (e) => e.actions.toOrderedList.isEnable(), +}; +export const orderedListItemMarkup: ToolbarItemMarkup = { + exec: (e) => toOrderedList(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Sink List ---- +export const sinkListItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'list__action_sink'), + icon: icons.sink, + hotkey: f.toView(A.SinkListItem), +}; +export const sinkListItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.sinkListItem.run(), + hintWhenDisabled: () => i18n('list_action_disabled'), + isActive: (e) => e.actions.sinkListItem.isActive(), + isEnable: (e) => e.actions.sinkListItem.isEnable(), +}; +export const sinkListItemMarkup: ToolbarItemMarkup = { + exec: (e) => sinkListItemCommand(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Lift List ---- +export const liftListItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'list__action_lift'), + icon: icons.lift, + hotkey: f.toView(A.LiftListItem), +}; +export const liftListItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.liftListItem.run(), + hintWhenDisabled: () => i18n('list_action_disabled'), + isActive: (e) => e.actions.liftListItem.isActive(), + isEnable: (e) => e.actions.liftListItem.isEnable(), +}; +export const liftListItemMarkup: ToolbarItemMarkup = { + exec: (e) => liftListItemCommand(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Toggle Heading Folding ---- +export const toggleHeadingFoldingItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + icon: icons.foldingHeading, + title: () => i18n('folding-heading'), + hint: () => i18n('folding-heading__hint'), +}; +export const toggleHeadingFoldingItemWysiwyg: ToolbarItemWysiwyg = { + isActive: (editor) => editor.actions.toggleHeadingFolding?.isActive() ?? false, + isEnable: (editor) => editor.actions.toggleHeadingFolding?.isEnable() ?? false, + exec: (editor) => editor.actions.toggleHeadingFolding.run(), + condition: 'enabled', +}; + +// ---- Text Context ---- +export const textContextItemView: ToolbarItemView = { + type: ToolbarDataType.ReactComponent, +}; +export const textContextItemWisywig: ToolbarItemWysiwyg = { + component: WToolbarTextSelect, + width: 0, + condition: ({selection: {$from, $to}, schema}) => { + if (!$from.sameParent($to)) return false; + const {parent} = $from; + return parent.type === pType(schema) || parent.type === headingType(schema); + }, +}; + +// ---- Paragraph ---- +export const paragraphItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'text'), + icon: icons.text, + hotkey: f.toView(A.Text), + doNotActivateList: true, +}; +export const paragraphItemWisywig: ToolbarItemWysiwyg = { + exec: (e) => e.actions.toParagraph.run(), + isActive: (e) => e.actions.toParagraph.isActive(), + isEnable: (e) => e.actions.toParagraph.isEnable(), +}; +export const paragraphItemMarkup: ToolbarItemMarkup = { + exec: noop, + isActive: inactive, + isEnable: enable, +}; + +// --- Colorify ---- +export const colorifyItemView: ToolbarItemView = { + type: ToolbarDataType.ReactComponent, +}; +export const colorifyItemWysiwyg: ToolbarItemWysiwyg = { + component: WToolbarColors, + width: 42, +}; +export const colorifyItemMarkup: ToolbarItemMarkup = { + component: MToolbarColors, + width: 42, +}; + +// ---- GPT ---- +export const gptItemView: ToolbarItemView = { + type: ToolbarDataType.SingleButton, + title: i18n.bind(null, 'gpt'), + hotkey: gptHotKeys.openGptKeyTooltip, + icon: icons.gpt, +}; +export const gptItemWysiwyg: ToolbarItemWysiwyg = { + exec: (e) => e.actions.addGptWidget.run({}), + isActive: (e) => e.actions.addGptWidget.isActive(), + isEnable: (e) => e.actions.addGptWidget.isEnable(), +}; +export const gptItemMarkup: ToolbarItemMarkup = { + exec: (e) => insertMermaidDiagram(e.cm), + isActive: inactive, + isEnable: enable, +}; + +// ---- Heading list ---- +export const headingListItemView: ToolbarItemView = { + type: ToolbarDataType.ListButton, + icon: icons.headline, + title: i18n.bind(null, 'heading'), + withArrow: true, +}; + +// ---- Lists list ---- +export const listsListItemView: ToolbarItemView = { + type: ToolbarDataType.ListButton, + icon: icons.bulletList, + withArrow: true, + title: i18n.bind(null, 'list'), +}; + +// ---- Move list ---- +export const moveListItemView: ToolbarItemView = { + type: ToolbarDataType.ListButton, + icon: icons.lift, + withArrow: true, + title: i18n.bind(null, 'move_list'), +}; + +// ---- Code list ---- +export const codeBlocksListItemView: ToolbarItemView = { + type: ToolbarDataType.ListButton, + icon: icons.code, + title: i18n.bind(null, 'code'), + withArrow: true, +}; + +// ---- Math list ---- +export const mathListItemView: ToolbarItemView = { + type: ToolbarDataType.ListButton, + icon: icons.functionInline, + withArrow: true, + title: i18n.bind(null, 'math'), +}; diff --git a/src/modules/toolbars/presets.ts b/src/modules/toolbars/presets.ts new file mode 100644 index 00000000..531f8324 --- /dev/null +++ b/src/modules/toolbars/presets.ts @@ -0,0 +1,614 @@ +import {ActionName as Action} from '../../bundle/config/action-names'; + +import {ListName as List, ToolbarName as Toolbar} from './constants'; +import { + boldItemMarkup, + boldItemView, + boldItemWysiwyg, + bulletListItemMarkup, + bulletListItemView, + bulletListItemWysiwyg, + checkboxItemMarkup, + checkboxItemView, + checkboxItemWysiwyg, + codeBlockItemMarkup, + codeBlockItemView, + codeBlockItemWysiwyg, + codeBlocksListItemView, + codeItemMarkup, + codeItemView, + codeItemWysiwyg, + colorifyItemMarkup, + colorifyItemView, + colorifyItemWysiwyg, + cutItemMarkup, + cutItemView, + cutItemWysiwyg, + emojiItemMarkup, + emojiItemView, + emojiItemWysiwyg, + fileItemMarkup, + fileItemView, + fileItemWysiwyg, + filePopupItemView, + heading1ItemMarkup, + heading1ItemView, + heading1ItemWysiwyg, + heading2ItemMarkup, + heading2ItemView, + heading2ItemWysiwyg, + heading3ItemMarkup, + heading3ItemView, + heading3ItemWysiwyg, + heading4ItemMarkup, + heading4ItemView, + heading4ItemWysiwyg, + heading5ItemMarkup, + heading5ItemView, + heading5ItemWysiwyg, + heading6ItemMarkup, + heading6ItemView, + heading6ItemWysiwyg, + headingListItemView, + hruleItemMarkup, + hruleItemView, + hruleItemWysiwyg, + imageItemMarkup, + imageItemView, + imageItemWysiwyg, + imagePopupItemView, + italicItemMarkup, + italicItemView, + italicItemWysiwyg, + liftListItemMarkup, + liftListItemView, + liftListItemWysiwyg, + linkItemMarkup, + linkItemView, + linkItemWysiwyg, + listsListItemView, + markedItemMarkup, + markedItemView, + markedItemWysiwyg, + monospaceItemMarkup, + monospaceItemView, + monospaceItemWysiwyg, + noteItemMarkup, + noteItemView, + noteItemWysiwyg, + orderedListItemMarkup, + orderedListItemView, + orderedListItemWysiwyg, + paragraphItemMarkup, + paragraphItemView, + paragraphItemWisywig, + quoteItemMarkup, + quoteItemView, + quoteItemWysiwyg, + redoItemMarkup, + redoItemView, + redoItemWysiwyg, + sinkListItemMarkup, + sinkListItemView, + sinkListItemWysiwyg, + strikethroughItemMarkup, + strikethroughItemView, + tableItemMarkup, + tableItemView, + tableItemWysiwyg, + underlineItemMarkup, + underlineItemView, + underlineItemWysiwyg, + undoItemMarkup, + undoItemView, + undoItemWysiwyg, +} from './items'; +import {ToolbarsPreset} from './types'; + +// presets +export const zero: ToolbarsPreset = { + items: { + [Action.undo]: { + view: undoItemView, + wysiwyg: undoItemWysiwyg, + markup: undoItemMarkup, + }, + [Action.redo]: { + view: redoItemView, + wysiwyg: redoItemWysiwyg, + markup: redoItemMarkup, + }, + }, + orders: { + [Toolbar.wysiwygMain]: [[Action.undo, Action.redo]], + [Toolbar.markupMain]: [[Action.undo, Action.redo]], + }, +}; + +export const commonmark: ToolbarsPreset = { + items: { + ...zero.items, + [Action.bold]: { + view: boldItemView, + wysiwyg: boldItemWysiwyg, + markup: boldItemMarkup, + }, + [Action.italic]: { + view: italicItemView, + wysiwyg: italicItemWysiwyg, + markup: italicItemMarkup, + }, + [List.heading]: { + view: headingListItemView, + }, + [Action.paragraph]: { + view: paragraphItemView, + wysiwyg: paragraphItemWisywig, + markup: paragraphItemMarkup, + }, + [Action.heading1]: { + view: heading1ItemView, + wysiwyg: heading1ItemWysiwyg, + markup: heading1ItemMarkup, + }, + [Action.heading2]: { + view: heading2ItemView, + wysiwyg: heading2ItemWysiwyg, + markup: heading2ItemMarkup, + }, + [Action.heading3]: { + view: heading3ItemView, + wysiwyg: heading3ItemWysiwyg, + markup: heading3ItemMarkup, + }, + [Action.heading4]: { + view: heading4ItemView, + wysiwyg: heading4ItemWysiwyg, + markup: heading4ItemMarkup, + }, + [Action.heading5]: { + view: heading5ItemView, + wysiwyg: heading5ItemWysiwyg, + markup: heading5ItemMarkup, + }, + [Action.heading6]: { + view: heading6ItemView, + wysiwyg: heading6ItemWysiwyg, + markup: heading6ItemMarkup, + }, + [List.lists]: { + view: listsListItemView, + }, + [Action.bulletList]: { + view: bulletListItemView, + wysiwyg: bulletListItemWysiwyg, + markup: bulletListItemMarkup, + }, + [Action.orderedList]: { + view: orderedListItemView, + wysiwyg: orderedListItemWysiwyg, + markup: orderedListItemMarkup, + }, + [Action.sinkListItem]: { + view: sinkListItemView, + wysiwyg: sinkListItemWysiwyg, + markup: sinkListItemMarkup, + }, + [Action.liftListItem]: { + view: liftListItemView, + wysiwyg: liftListItemWysiwyg, + markup: liftListItemMarkup, + }, + [Action.link]: { + view: linkItemView, + wysiwyg: linkItemWysiwyg, + markup: linkItemMarkup, + }, + [Action.quote]: { + view: quoteItemView, + wysiwyg: quoteItemWysiwyg, + markup: quoteItemMarkup, + }, + [List.code]: { + view: codeBlocksListItemView, + }, + [Action.codeInline]: { + view: codeItemView, + wysiwyg: codeItemWysiwyg, + markup: codeItemMarkup, + }, + [Action.codeBlock]: { + view: codeBlockItemView, + wysiwyg: codeBlockItemWysiwyg, + markup: codeBlockItemMarkup, + }, + [Action.horizontalRule]: { + view: hruleItemView, + wysiwyg: hruleItemWysiwyg, + markup: hruleItemMarkup, + }, + }, + orders: { + [Toolbar.wysiwygMain]: [ + [Action.undo, Action.redo], + [Action.bold, Action.italic], + [ + { + id: List.heading, + items: [ + Action.paragraph, + Action.heading1, + Action.heading2, + Action.heading3, + Action.heading4, + Action.heading5, + Action.heading6, + ], + }, + { + id: List.lists, + items: [ + Action.bulletList, + Action.orderedList, + Action.sinkListItem, + Action.liftListItem, + ], + }, + Action.link, + Action.quote, + { + id: List.code, + items: [Action.codeInline, Action.codeBlock], + }, + ], + ], + [Toolbar.markupMain]: [ + [Action.undo, Action.redo], + [Action.bold, Action.italic], + [ + { + id: List.heading, + items: [ + Action.paragraph, + Action.heading1, + Action.heading2, + Action.heading3, + Action.heading4, + Action.heading5, + Action.heading6, + ], + }, + { + id: List.lists, + items: [ + Action.bulletList, + Action.orderedList, + Action.sinkListItem, + Action.liftListItem, + ], + }, + Action.link, + Action.quote, + { + id: List.code, + items: [Action.codeInline, Action.codeBlock], + }, + ], + ], + [Toolbar.wysiwygHidden]: [[Action.horizontalRule]], + [Toolbar.markupHidden]: [[Action.horizontalRule]], + }, +}; + +export const defaultPreset: ToolbarsPreset = { + items: { + ...commonmark.items, + [Action.strike]: { + view: strikethroughItemView, + wysiwyg: sinkListItemWysiwyg, + markup: strikethroughItemMarkup, + }, + }, + orders: { + [Toolbar.wysiwygMain]: [ + [Action.undo, Action.redo], + [Action.bold, Action.italic, Action.strike], + [ + { + id: List.heading, + items: [ + Action.paragraph, + Action.heading1, + Action.heading2, + Action.heading3, + Action.heading4, + Action.heading5, + Action.heading6, + ], + }, + { + id: List.lists, + items: [ + Action.bulletList, + Action.orderedList, + Action.sinkListItem, + Action.liftListItem, + ], + }, + Action.link, + Action.quote, + { + id: List.code, + items: [Action.codeInline, Action.codeBlock], + }, + ], + ], + [Toolbar.markupMain]: [ + [Action.undo, Action.redo], + [Action.bold, Action.italic, Action.strike], + [ + { + id: List.heading, + items: [ + Action.paragraph, + Action.heading1, + Action.heading2, + Action.heading3, + Action.heading4, + Action.heading5, + Action.heading6, + ], + }, + { + id: List.lists, + items: [ + Action.bulletList, + Action.orderedList, + Action.sinkListItem, + Action.liftListItem, + ], + }, + Action.link, + Action.quote, + { + id: List.code, + items: [Action.codeInline, Action.codeBlock], + }, + ], + ], + [Toolbar.wysiwygHidden]: [[Action.horizontalRule]], + [Toolbar.markupHidden]: [[Action.horizontalRule]], + }, +}; + +export const yfm: ToolbarsPreset = { + items: { + ...defaultPreset.items, + [Action.underline]: { + view: underlineItemView, + wysiwyg: underlineItemWysiwyg, + markup: underlineItemMarkup, + }, + [Action.mono]: { + view: monospaceItemView, + wysiwyg: monospaceItemWysiwyg, + markup: monospaceItemMarkup, + }, + [Action.note]: { + view: noteItemView, + wysiwyg: noteItemWysiwyg, + markup: noteItemMarkup, + }, + [Action.cut]: { + view: cutItemView, + wysiwyg: cutItemWysiwyg, + markup: cutItemMarkup, + }, + [Action.image]: { + view: imageItemView, + wysiwyg: imageItemWysiwyg, + }, + [Action.imagePopup]: { + view: imagePopupItemView, + markup: imageItemMarkup, + }, + [Action.file]: { + view: fileItemView, + wysiwyg: fileItemWysiwyg, + }, + [Action.filePopup]: { + view: filePopupItemView, + markup: fileItemMarkup, + }, + [Action.table]: { + view: tableItemView, + wysiwyg: tableItemWysiwyg, + markup: tableItemMarkup, + }, + [Action.checkbox]: { + view: checkboxItemView, + wysiwyg: checkboxItemWysiwyg, + markup: checkboxItemMarkup, + }, + [Action.tabs]: { + view: tableItemView, + wysiwyg: tableItemWysiwyg, + markup: tableItemMarkup, + }, + }, + orders: { + [Toolbar.wysiwygMain]: [ + [Action.undo, Action.redo], + [Action.bold, Action.italic, Action.underline, Action.strike, Action.mono], + [ + { + id: List.heading, + items: [ + Action.paragraph, + Action.heading1, + Action.heading2, + Action.heading3, + Action.heading4, + Action.heading5, + Action.heading6, + ], + }, + { + id: List.lists, + items: [ + Action.bulletList, + Action.orderedList, + Action.sinkListItem, + Action.liftListItem, + ], + }, + Action.link, + Action.note, + Action.cut, + Action.quote, + { + id: List.code, + items: [Action.codeInline, Action.codeBlock], + }, + ], + [Action.image, Action.file, Action.table, Action.checkbox], + ], + [Toolbar.markupMain]: [ + [Action.undo, Action.redo], + [Action.bold, Action.italic, Action.underline, Action.strike, Action.mono], + [ + { + id: List.heading, + items: [ + Action.paragraph, + Action.heading1, + Action.heading2, + Action.heading3, + Action.heading4, + Action.heading5, + Action.heading6, + ], + }, + { + id: List.lists, + items: [ + Action.bulletList, + Action.orderedList, + Action.sinkListItem, + Action.liftListItem, + ], + }, + Action.link, + Action.note, + Action.cut, + Action.quote, + { + id: List.code, + items: [Action.codeInline, Action.codeBlock], + }, + ], + [Action.imagePopup, Action.filePopup, Action.table, Action.checkbox], + ], + [Toolbar.wysiwygHidden]: [[Action.horizontalRule, Action.tabs]], + [Toolbar.markupHidden]: [[Action.horizontalRule, Action.tabs]], + }, +}; + +export const full: ToolbarsPreset = { + items: { + ...yfm.items, + [Action.mark]: { + view: markedItemView, + wysiwyg: markedItemWysiwyg, + markup: markedItemMarkup, + }, + [Action.colorify]: { + view: colorifyItemView, + wysiwyg: colorifyItemWysiwyg, + markup: colorifyItemMarkup, + }, + [Action.emoji]: { + view: emojiItemView, + wysiwyg: emojiItemWysiwyg, + markup: emojiItemMarkup, + }, + }, + orders: { + [Toolbar.wysiwygMain]: [ + [Action.undo, Action.redo], + [Action.bold, Action.italic, Action.underline, Action.strike, Action.mono, Action.mark], + [ + { + id: List.heading, + items: [ + Action.paragraph, + Action.heading1, + Action.heading2, + Action.heading3, + Action.heading4, + Action.heading5, + Action.heading6, + ], + }, + { + id: List.lists, + items: [ + Action.bulletList, + Action.orderedList, + Action.sinkListItem, + Action.liftListItem, + ], + }, + Action.colorify, + Action.link, + Action.note, + Action.cut, + Action.quote, + { + id: List.code, + items: [Action.codeInline, Action.codeBlock], + }, + ], + [Action.image, Action.file, Action.table, Action.checkbox], + ], + [Toolbar.markupMain]: [ + [Action.undo, Action.redo], + [Action.bold, Action.italic, Action.underline, Action.strike, Action.mono, Action.mark], + [ + { + id: List.heading, + items: [ + Action.paragraph, + Action.heading1, + Action.heading2, + Action.heading3, + Action.heading4, + Action.heading5, + Action.heading6, + ], + }, + { + id: List.lists, + items: [ + Action.bulletList, + Action.orderedList, + Action.sinkListItem, + Action.liftListItem, + ], + }, + Action.colorify, + Action.link, + Action.note, + Action.cut, + Action.quote, + { + id: List.code, + items: [Action.codeInline, Action.codeBlock], + }, + ], + [Action.imagePopup, Action.filePopup, Action.table, Action.checkbox], + ], + [Toolbar.wysiwygHidden]: [[Action.horizontalRule, Action.emoji, Action.tabs]], + [Toolbar.markupHidden]: [[Action.horizontalRule, Action.emoji, Action.tabs]], + }, +}; diff --git a/src/modules/toolbars/types.ts b/src/modules/toolbars/types.ts new file mode 100644 index 00000000..ec3e2de6 --- /dev/null +++ b/src/modules/toolbars/types.ts @@ -0,0 +1,86 @@ +import type {RefObject} from 'react'; + +import type {HotkeyProps} from '@gravity-ui/uikit'; +import type {EditorState} from 'prosemirror-state'; + +import type {ActionStorage} from '../../core'; +import type {CodeEditor} from '../../markup'; +import type {ToolbarBaseProps, ToolbarDataType, ToolbarIconData} from '../../toolbar'; + +// Items +export type ToolbarItemId = string & {}; +export type ToolbarListId = string & {}; + +export interface ToolbarList { + id: ToolbarListId; + items: ToolbarItemId[]; +} + +/** + * The default value for the `type` property is `ToolbarDataType.SingleButton`. + */ +export type ToolbarItemView = { + className?: string; + hint?: string | (() => string); + hotkey?: HotkeyProps['value']; + type?: ToolbarDataType; + doNotActivateList?: boolean; +} & (T extends ToolbarDataType.SingleButton + ? { + icon: ToolbarIconData; + title: string | (() => string); + } + : T extends ToolbarDataType.ListButton + ? { + withArrow?: boolean; + icon: ToolbarIconData; + title: string | (() => string); + } + : {}); + +export interface EditorActions { + exec(editor: E): void; + isActive(editor: E): boolean; + isEnable(editor: E): boolean; +} + +type ToolbarItemEditor = Partial> & { + hintWhenDisabled?: boolean | string | (() => string); + condition?: ((state: EditorState) => void) | 'enabled'; +} & (T extends ToolbarDataType.ButtonPopup + ? { + renderPopup: ( + props: ToolbarBaseProps & { + hide: () => void; + anchorRef: RefObject; + }, + ) => React.ReactNode; + } + : T extends ToolbarDataType.ReactComponent + ? { + width: number; + component: React.ComponentType>; + } + : {}); + +export type ToolbarItemWysiwyg = + ToolbarItemEditor; +export type ToolbarItemMarkup = + ToolbarItemEditor; + +export type ToolbarItem = { + view: ToolbarItemView; + wysiwyg?: ToolbarItemWysiwyg; + markup?: ToolbarItemMarkup; +}; +export type ToolbarsItems = Record>; + +// Orders +export type ToolbarId = string; +export type ToolbarOrders = (ToolbarList | ToolbarItemId)[][]; +export type ToolbarsOrders = Record; + +export interface ToolbarsPreset { + items: ToolbarsItems; + orders: ToolbarsOrders; +} diff --git a/src/toolbar/types.ts b/src/toolbar/types.ts index 50da041c..4061c797 100644 --- a/src/toolbar/types.ts +++ b/src/toolbar/types.ts @@ -43,7 +43,9 @@ export enum ToolbarDataType { SingleButton = 's-button', ListButton = 'list-b', ButtonPopup = 'b-popup', + /** @deprecated Use ReactComponent type instead */ ReactNode = 'r-node', + /** @deprecated Use ReactComponent type instead */ ReactNodeFn = 'r-node-fn', ReactComponent = 'r-component', } @@ -90,6 +92,9 @@ export type ToolbarListButtonItemData = ToolbarItemData & { doNotActivateList?: boolean; }; +/** + * @deprecated Use ReactComponent type instead + * */ export type ToolbarReactNodeData = { id: string; type: ToolbarDataType.ReactNode; @@ -97,6 +102,9 @@ export type ToolbarReactNodeData = { content: React.ReactNode; }; +/** + * @deprecated Use ReactComponent type instead + * */ export type ToolbarReactNodeFnData = { id: string; type: ToolbarDataType.ReactNodeFn; From db1d1cabd85c317bcd1bd63ff8c56d3af0e5dbb2 Mon Sep 17 00:00:00 2001 From: Sergey Makhnatkin Date: Fri, 20 Dec 2024 10:03:03 +0100 Subject: [PATCH 13/19] chore(docs, demo): updated stories names, updated README (#532) --- .storybook/preview.ts | 2 +- README-ru.md | 14 +++++++++----- README.md | 14 ++++++++------ .../stories/css-variables/CSSVariables.stories.tsx | 2 +- docs/how-to-add-editor-with-create-react-app.md | 2 +- docs/how-to-add-editor-with-nextjs.md | 2 +- docs/how-to-add-preview.md | 2 +- docs/how-to-connect-gpt-extensions.md | 2 +- docs/how-to-connect-html-extension.md | 2 +- docs/how-to-connect-latex-extension.md | 2 +- docs/how-to-connect-mermaid-extension.md | 2 +- docs/how-to-customize-the-editor.md | 2 +- 12 files changed, 27 insertions(+), 21 deletions(-) diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 875a0042..163d052f 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -12,7 +12,7 @@ const preview: Preview = { }, options: { storySort: { - order: ['Playground', 'Docs', 'Extensions', ['Presets', '*'], '*'], + order: ['Playground', 'Docs', 'Extensions', 'Settings', ['Presets', '*'], '*'], }, }, controls: { diff --git a/README-ru.md b/README-ru.md index 6c8269e5..4047d071 100644 --- a/README-ru.md +++ b/README-ru.md @@ -51,6 +51,7 @@ function Editor({onSubmit}) { return ; } ``` + Полезные ссылки: - [Как подключить редактор в Create React App](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-install-create-react-app--docs) - [Как добавить предварительный просмотр для режима разметки](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-develop-preview--docs) @@ -61,7 +62,13 @@ function Editor({onSubmit}) { - [Как добавить расширение GPT](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-connect-gpt--docs) - [Как добавить расширение привязки текста в Markdown](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-develop-extension-with-popup--docs) +### Разработка + +Для запуска Storybook в режиме разработки выполните следующую команду: +```shell +npm start +``` ### i18n @@ -77,10 +84,7 @@ configure({ Обязательно сделайте вызов `configure()` из [UIKit](https://github.com/gravity-ui/uikit?tab=readme-ov-file#i18n) и других UI-библиотек. -## Разработка -Для запуска Storybook в режиме разработки выполните следующую команду: +### Участие в разработке -```shell -npm start -``` +- [Информация для контрибьюетров](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-contributing--docs) diff --git a/README.md b/README.md index bfbea225..6f435e18 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,12 @@ Read more: - [How to add GPT extension](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-connect-gpt--docs) - [How to add text binding extension in markdown](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-develop-extension-with-popup--docs) +### Development +To start the dev storybook + +```shell +npm start +``` ### i18n @@ -77,10 +83,6 @@ configure({ Don't forget to call `configure()` from [UIKit](https://github.com/gravity-ui/uikit?tab=readme-ov-file#i18n) and other UI libraries. -## Development +### Contributing -To start the dev storybook - -```shell -npm start -``` +- [Contributor Guidelines](https://preview.gravity-ui.com/md-editor/?path=/docs/docs-contributing--docs) diff --git a/demo/stories/css-variables/CSSVariables.stories.tsx b/demo/stories/css-variables/CSSVariables.stories.tsx index 89a7faf1..17de4ed0 100644 --- a/demo/stories/css-variables/CSSVariables.stories.tsx +++ b/demo/stories/css-variables/CSSVariables.stories.tsx @@ -33,6 +33,6 @@ export const Story: StoryObj = { Story.storyName = 'Custom CSS Variables'; export default { - title: 'Experiments / Custom CSS Variables', + title: 'Settings / Custom CSS Variables', component, }; diff --git a/docs/how-to-add-editor-with-create-react-app.md b/docs/how-to-add-editor-with-create-react-app.md index 104bfa90..97f3fb6e 100644 --- a/docs/how-to-add-editor-with-create-react-app.md +++ b/docs/how-to-add-editor-with-create-react-app.md @@ -1,4 +1,4 @@ -##### Install / Create react app +##### Getting started / Create react app ## Installation Guide diff --git a/docs/how-to-add-editor-with-nextjs.md b/docs/how-to-add-editor-with-nextjs.md index 31741db4..c677a9c7 100644 --- a/docs/how-to-add-editor-with-nextjs.md +++ b/docs/how-to-add-editor-with-nextjs.md @@ -1,4 +1,4 @@ -##### Install / NextJS +##### Getting started / NextJS ## Connection and Configuration This document provides instructions for configuring Webpack and Turbopack to avoid issues related to the 'fs' module and for connecting the editor on the nextjs client side. diff --git a/docs/how-to-add-preview.md b/docs/how-to-add-preview.md index 42c94788..e7bdb5ff 100644 --- a/docs/how-to-add-preview.md +++ b/docs/how-to-add-preview.md @@ -1,4 +1,4 @@ -##### Develop / Preview +##### Getting started / Preview ## How to Add Preview for Markup Mode diff --git a/docs/how-to-connect-gpt-extensions.md b/docs/how-to-connect-gpt-extensions.md index 97bd75b3..d09a2721 100644 --- a/docs/how-to-connect-gpt-extensions.md +++ b/docs/how-to-connect-gpt-extensions.md @@ -1,4 +1,4 @@ -##### Connect / GPT +##### Extensions / GPT ## How to connect GPT extensions to editor diff --git a/docs/how-to-connect-html-extension.md b/docs/how-to-connect-html-extension.md index fde3961d..b0dca349 100644 --- a/docs/how-to-connect-html-extension.md +++ b/docs/how-to-connect-html-extension.md @@ -1,4 +1,4 @@ -##### Connect / Html block +##### Extensions / Html block ## How to Connect the HTML Extension in the Editor diff --git a/docs/how-to-connect-latex-extension.md b/docs/how-to-connect-latex-extension.md index 39558aa0..9a4047bc 100644 --- a/docs/how-to-connect-latex-extension.md +++ b/docs/how-to-connect-latex-extension.md @@ -1,4 +1,4 @@ -##### Connect / Latex extension +##### Extensions / Latex extension ## How to Connect the Latex Extension in the Editor diff --git a/docs/how-to-connect-mermaid-extension.md b/docs/how-to-connect-mermaid-extension.md index c3190054..aae109a8 100644 --- a/docs/how-to-connect-mermaid-extension.md +++ b/docs/how-to-connect-mermaid-extension.md @@ -1,4 +1,4 @@ -##### Connect / Mermaid Extension +##### Extensions / Mermaid Extension ## How to Connect the Mermaid Extension in the Editor diff --git a/docs/how-to-customize-the-editor.md b/docs/how-to-customize-the-editor.md index 3ae64cc7..30abd9ab 100644 --- a/docs/how-to-customize-the-editor.md +++ b/docs/how-to-customize-the-editor.md @@ -1,4 +1,4 @@ -##### Develop / Editor customization +##### Getting started / Editor customization ## How to customize the editor You can use CSS variables to make editor contents fit your own needs From 300bfdf6d383a6e0452dc098579561f0144fc459 Mon Sep 17 00:00:00 2001 From: Gravity UI Bot <111915794+gravity-ui-bot@users.noreply.github.com> Date: Fri, 20 Dec 2024 12:56:20 +0300 Subject: [PATCH 14/19] chore(main): release 14.9.0 (#529) --- CHANGELOG.md | 13 +++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4c24db0..a25b387f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [14.9.0](https://github.com/gravity-ui/markdown-editor/compare/v14.8.0...v14.9.0) (2024-12-20) + + +### Features + +* **markup:** smart re-indent on paste ([#530](https://github.com/gravity-ui/markdown-editor/issues/530)) ([15767d7](https://github.com/gravity-ui/markdown-editor/commit/15767d7d3b0334126e34149d811ce6b6d62909d2)) +* **toolbars:** restructured toolbar configuration and presets ([#509](https://github.com/gravity-ui/markdown-editor/issues/509)) ([3ebf14f](https://github.com/gravity-ui/markdown-editor/commit/3ebf14fd580ce29dc0133715cd2cb6bb6ea4ca8a)) + + +### Bug Fixes + +* **Link:** fixed pasting link to empty selection ([#528](https://github.com/gravity-ui/markdown-editor/issues/528)) ([bd52bee](https://github.com/gravity-ui/markdown-editor/commit/bd52bee93aceaf0af5bd9b8da284e93338b89a32)) + ## [14.8.0](https://github.com/gravity-ui/markdown-editor/compare/v14.7.0...v14.8.0) (2024-12-17) diff --git a/package-lock.json b/package-lock.json index cffa164d..64278275 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@gravity-ui/markdown-editor", - "version": "14.8.0", + "version": "14.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gravity-ui/markdown-editor", - "version": "14.8.0", + "version": "14.9.0", "license": "MIT", "dependencies": { "@bem-react/classname": "^1.6.0", diff --git a/package.json b/package.json index 1c280ed6..cb78b7a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gravity-ui/markdown-editor", - "version": "14.8.0", + "version": "14.9.0", "description": "Markdown wysiwyg and markup editor", "license": "MIT", "repository": { From deafe20cc364474580d993801e32fee227cc8832 Mon Sep 17 00:00:00 2001 From: Sergey Makhnatkin Date: Mon, 23 Dec 2024 16:14:52 +0300 Subject: [PATCH 15/19] feat(toolbars): updated flattenPreset (#531) --- src/bundle/MarkdownEditorView.tsx | 2 +- .../toolbar/utils/flattenPreset.test.tsx | 311 ++++++++++++++++++ src/bundle/toolbar/utils/flattenPreset.ts | 15 + .../{utils.ts => utils/toolbarsConfigs.ts} | 49 +-- 4 files changed, 353 insertions(+), 24 deletions(-) create mode 100644 src/bundle/toolbar/utils/flattenPreset.test.tsx create mode 100644 src/bundle/toolbar/utils/flattenPreset.ts rename src/bundle/toolbar/{utils.ts => utils/toolbarsConfigs.ts} (69%) diff --git a/src/bundle/MarkdownEditorView.tsx b/src/bundle/MarkdownEditorView.tsx index d0fe9d6f..98cbb49f 100644 --- a/src/bundle/MarkdownEditorView.tsx +++ b/src/bundle/MarkdownEditorView.tsx @@ -20,7 +20,7 @@ import {MToolbarData, MToolbarItemData, WToolbarData, WToolbarItemData} from './ import {useMarkdownEditorContext} from './context'; import {EditorSettings, EditorSettingsProps} from './settings'; import {stickyCn} from './sticky'; -import {getToolbarsConfigs} from './toolbar/utils'; +import {getToolbarsConfigs} from './toolbar/utils/toolbarsConfigs'; import type {MarkdownEditorMode} from './types'; import '../styles/styles.scss'; diff --git a/src/bundle/toolbar/utils/flattenPreset.test.tsx b/src/bundle/toolbar/utils/flattenPreset.test.tsx new file mode 100644 index 00000000..341160ac --- /dev/null +++ b/src/bundle/toolbar/utils/flattenPreset.test.tsx @@ -0,0 +1,311 @@ +import React from 'react'; + +import {ToolbarDataType} from '../../../toolbar/types'; +import type {WToolbarData} from '../../config/wysiwyg'; + +import {flattenPreset} from './flattenPreset'; + +interface IconProps {} +const Icon: React.FC = () => { + return
; +}; + +interface WToolbarColorsProps {} +const WToolbarColors: React.FC = () => { + return
; +}; + +const input: WToolbarData = [ + [ + { + type: ToolbarDataType.SingleButton, + id: 'undo', + icon: {data: Icon}, + hotkey: 'mod+z', + hintWhenDisabled: false, + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + { + type: ToolbarDataType.SingleButton, + id: 'redo', + icon: {data: Icon}, + hotkey: 'mod+shift+z', + hintWhenDisabled: false, + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + ], + [ + { + type: ToolbarDataType.SingleButton, + id: 'bold', + icon: {data: Icon}, + hotkey: 'mod+b', + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + { + type: ToolbarDataType.SingleButton, + id: 'italic', + icon: {data: Icon}, + hotkey: 'mod+i', + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + { + type: ToolbarDataType.SingleButton, + id: 'underline', + icon: {data: Icon}, + hotkey: 'mod+u', + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + { + type: ToolbarDataType.SingleButton, + id: 'strike', + icon: {data: Icon}, + hotkey: 'mod+shift+s', + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + { + type: ToolbarDataType.SingleButton, + id: 'mono', + icon: {data: Icon}, + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + { + type: ToolbarDataType.SingleButton, + id: 'mark', + icon: {data: Icon}, + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + ], + [ + { + type: ToolbarDataType.ListButton, + id: 'heading', + icon: {data: Icon}, + withArrow: true, + title: '', + data: [ + { + id: 'paragraph', + icon: {data: Icon}, + hotkey: 'cmd+alt+0', + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + { + id: 'heading1', + icon: {data: Icon}, + hotkey: 'cmd+alt+1', + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + { + id: 'heading2', + icon: {data: Icon}, + hotkey: 'cmd+alt+2', + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + { + id: 'heading3', + icon: {data: Icon}, + hotkey: 'cmd+alt+3', + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + { + id: 'heading4', + icon: {data: Icon}, + hotkey: 'cmd+alt+4', + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + { + id: 'heading5', + icon: {data: Icon}, + hotkey: 'cmd+alt+5', + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + { + id: 'heading6', + icon: {data: Icon}, + hotkey: 'cmd+alt+6', + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + ], + }, + { + type: ToolbarDataType.ListButton, + id: 'lists', + icon: {data: Icon}, + withArrow: true, + title: '', + data: [ + { + id: 'bulletList', + icon: {data: Icon}, + hotkey: 'mod+shift+l', + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + { + id: 'orderedList', + icon: {data: Icon}, + hotkey: 'mod+shift+m', + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + { + id: 'sinkListItem', + icon: {data: Icon}, + hotkey: 'tab', + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + { + id: 'liftListItem', + icon: {data: Icon}, + hotkey: 'shift+tab', + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + ], + }, + { + type: ToolbarDataType.ReactComponent, + id: 'colorify', + width: 42, + component: WToolbarColors, + }, + { + type: ToolbarDataType.SingleButton, + id: 'link', + icon: {data: Icon}, + hotkey: 'mod+k', + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + { + type: ToolbarDataType.SingleButton, + id: 'note', + icon: {data: Icon}, + hotkey: 'cmd+alt+8', + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + { + type: ToolbarDataType.SingleButton, + id: 'cut', + icon: {data: Icon}, + hotkey: 'cmd+alt+7', + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + { + type: ToolbarDataType.SingleButton, + id: 'quote', + icon: {data: Icon}, + hotkey: 'mod+shift+.', + exec: () => {}, + isActive: () => false, + isEnable: () => false, + title: '', + }, + ], +]; + +const expectedIds = [ + 'undo', + 'redo', + 'bold', + 'italic', + 'underline', + 'strike', + 'mono', + 'mark', + 'paragraph', + 'heading1', + 'heading2', + 'heading3', + 'heading4', + 'heading5', + 'heading6', + 'bulletList', + 'orderedList', + 'sinkListItem', + 'liftListItem', + 'colorify', + 'link', + 'note', + 'cut', + 'quote', +]; + +describe('flattenPreset', () => { + it('should flatten nested toolbar data and return a correctly flattened structure', () => { + const result = flattenPreset(input); + const expectedOutput = expectedIds.map((id) => expect.objectContaining({id})); + + expect(result).toEqual(expect.arrayContaining(expectedOutput)); + }); + + it('should return a list of flattened IDs', () => { + const result = flattenPreset(input); + const resultIds = result.map((item) => item.id); + + expect(resultIds).toEqual(expectedIds); + }); +}); diff --git a/src/bundle/toolbar/utils/flattenPreset.ts b/src/bundle/toolbar/utils/flattenPreset.ts new file mode 100644 index 00000000..40412fa2 --- /dev/null +++ b/src/bundle/toolbar/utils/flattenPreset.ts @@ -0,0 +1,15 @@ +import {ToolbarDataType} from '../../../toolbar/types'; +import type {MToolbarData, MToolbarItemData, WToolbarData, WToolbarItemData} from '../../config'; + +export const flattenPreset = ( + config: T, +): T extends WToolbarData ? WToolbarItemData[] : MToolbarItemData[] => { + return config.flat().reduce<(WToolbarItemData | MToolbarItemData)[]>((acc, item) => { + if (item.type === ToolbarDataType.ListButton && Array.isArray(item.data)) { + return acc.concat(item.data); + } + + acc.push(item as WToolbarItemData | MToolbarItemData); + return acc; + }, []) as unknown as T extends WToolbarData ? WToolbarItemData[] : MToolbarItemData[]; +}; diff --git a/src/bundle/toolbar/utils.ts b/src/bundle/toolbar/utils/toolbarsConfigs.ts similarity index 69% rename from src/bundle/toolbar/utils.ts rename to src/bundle/toolbar/utils/toolbarsConfigs.ts index 94447bc1..a3dfcbf5 100644 --- a/src/bundle/toolbar/utils.ts +++ b/src/bundle/toolbar/utils/toolbarsConfigs.ts @@ -1,15 +1,17 @@ -import {ToolbarName} from '../../modules/toolbars/constants'; -import {commonmark, defaultPreset, full, yfm, zero} from '../../modules/toolbars/presets'; +import {ToolbarName} from '../../../modules/toolbars/constants'; +import {commonmark, defaultPreset, full, yfm, zero} from '../../../modules/toolbars/presets'; import type { ToolbarItem, ToolbarItemMarkup, ToolbarItemWysiwyg, ToolbarsPreset, -} from '../../modules/toolbars/types'; -import type {MToolbarData, MToolbarItemData, WToolbarData, WToolbarItemData} from '../../toolbar'; -import {ToolbarDataType, ToolbarIconData} from '../../toolbar'; -import type {MarkdownEditorViewProps} from '../MarkdownEditorView'; -import {MarkdownEditorPreset} from '../types'; +} from '../../../modules/toolbars/types'; +import type {MToolbarData, WToolbarData} from '../../../toolbar'; +import {ToolbarDataType, ToolbarIconData} from '../../../toolbar'; +import type {MarkdownEditorViewProps} from '../../MarkdownEditorView'; +import {MarkdownEditorPreset} from '../../types'; + +import {flattenPreset} from './flattenPreset'; const defaultPresets: Record = { zero, @@ -58,7 +60,7 @@ const transformItem = ( }; }; -export const createConfig = ( +export const createToolbarConfig = ( editorType: 'wysiwyg' | 'markup', toolbarPreset: ToolbarsPreset | MarkdownEditorPreset, toolbarName: string, @@ -84,13 +86,6 @@ export const createConfig = ( return toolbarData as T; }; -const flattenPreset = (config: T) => { - // TODO: @makhnatkin add logic for flatten - return (config[0] ?? []) as unknown as T extends WToolbarData - ? WToolbarItemData[] - : MToolbarItemData[]; -}; - interface GetToolbarsConfigsArgs { toolbarsPreset?: ToolbarsPreset; props: Pick< @@ -104,28 +99,36 @@ interface GetToolbarsConfigsArgs { } export const getToolbarsConfigs = ({toolbarsPreset, props, preset}: GetToolbarsConfigsArgs) => { const wysiwygToolbarConfig = toolbarsPreset - ? createConfig('wysiwyg', toolbarsPreset, ToolbarName.wysiwygMain) + ? createToolbarConfig('wysiwyg', toolbarsPreset, ToolbarName.wysiwygMain) : props.wysiwygToolbarConfig ?? - createConfig('wysiwyg', preset, ToolbarName.wysiwygMain); + createToolbarConfig('wysiwyg', preset, ToolbarName.wysiwygMain); const markupToolbarConfig = toolbarsPreset - ? createConfig('markup', toolbarsPreset, ToolbarName.markupMain) + ? createToolbarConfig('markup', toolbarsPreset, ToolbarName.markupMain) : props.markupToolbarConfig ?? - createConfig('markup', preset, ToolbarName.markupMain); + createToolbarConfig('markup', preset, ToolbarName.markupMain); const wysiwygHiddenActionsConfig = toolbarsPreset ? flattenPreset( - createConfig('wysiwyg', toolbarsPreset, ToolbarName.wysiwygHidden), + createToolbarConfig( + 'wysiwyg', + toolbarsPreset, + ToolbarName.wysiwygHidden, + ), ) : props.wysiwygHiddenActionsConfig ?? - flattenPreset(createConfig('wysiwyg', preset, ToolbarName.wysiwygHidden)); + flattenPreset( + createToolbarConfig('wysiwyg', preset, ToolbarName.wysiwygHidden), + ); const markupHiddenActionsConfig = toolbarsPreset ? flattenPreset( - createConfig('markup', toolbarsPreset, ToolbarName.markupHidden), + createToolbarConfig('markup', toolbarsPreset, ToolbarName.markupHidden), ) : props.markupHiddenActionsConfig ?? - flattenPreset(createConfig('markup', preset, ToolbarName.markupHidden)); + flattenPreset( + createToolbarConfig('markup', preset, ToolbarName.markupHidden), + ); return { wysiwygToolbarConfig, From cc026a3923dd87c0d519df2d7c53bfae910887b6 Mon Sep 17 00:00:00 2001 From: Sergey Makhnatkin Date: Mon, 23 Dec 2024 16:25:31 +0300 Subject: [PATCH 16/19] fix(toolbars): add reexport /modules/toolbars/types (#534) --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index 88cfe1ed..a7f503f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export * from './common'; export * from './core'; export * from './toolbar'; +export * from './modules/toolbars/types'; export * from './react-utils'; export * from './classname'; export * from './logger'; From a3f8b50c7fd973c3db6bfa7e92f981db8d6a11c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE=D0=B2=D0=B8=D1=87=20=D0=9C?= =?UTF-8?q?=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=90=D0=BD=D1=82=D0=BE=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B8=D1=87?= <87013925+PMAWorks@users.noreply.github.com> Date: Mon, 23 Dec 2024 18:23:30 +0300 Subject: [PATCH 17/19] feat: added support for an empty string (#505) --- demo/components/Playground.tsx | 3 ++ src/bundle/Editor.ts | 16 +++++- src/bundle/config/action-names.ts | 1 + src/bundle/types.ts | 6 +++ src/bundle/useMarkdownEditor.ts | 9 ++++ src/bundle/wysiwyg-preset.ts | 2 + src/core/Editor.ts | 4 ++ src/core/ExtensionsManager.ts | 20 ++++++- src/core/ParserTokensRegistry.ts | 5 +- src/core/markdown/Markdown.test.ts | 37 +++++++------ src/core/markdown/MarkdownParser.test.ts | 15 ++++-- src/core/markdown/MarkdownParser.ts | 17 ++++-- .../emptyRowTransformer.ts | 8 +++ .../ProseMirrorTransformer/getTransformers.ts | 20 +++++++ .../markdown/ProseMirrorTransformer/index.ts | 35 ++++++++++++ .../base/BaseSchema/BaseSchemaSpecs/index.ts | 29 ++++++++-- src/i18n/empty-row/en.json | 3 ++ src/i18n/empty-row/index.ts | 8 +++ src/i18n/empty-row/ru.json | 3 ++ .../codemirror/autocomplete/emptyRow.ts | 28 ++++++++++ src/markup/codemirror/autocomplete/index.ts | 19 +++++++ src/markup/codemirror/create.ts | 11 ++++ src/markup/codemirror/yfm.ts | 2 +- src/markup/commands/emptyRow.ts | 53 +++++++++++++++++++ src/markup/commands/index.ts | 1 + src/shortcuts/const.ts | 2 + src/shortcuts/default.ts | 2 + 27 files changed, 326 insertions(+), 33 deletions(-) create mode 100644 src/core/markdown/ProseMirrorTransformer/emptyRowTransformer.ts create mode 100644 src/core/markdown/ProseMirrorTransformer/getTransformers.ts create mode 100644 src/core/markdown/ProseMirrorTransformer/index.ts create mode 100644 src/i18n/empty-row/en.json create mode 100644 src/i18n/empty-row/index.ts create mode 100644 src/i18n/empty-row/ru.json create mode 100644 src/markup/codemirror/autocomplete/emptyRow.ts create mode 100644 src/markup/codemirror/autocomplete/index.ts create mode 100644 src/markup/commands/emptyRow.ts diff --git a/demo/components/Playground.tsx b/demo/components/Playground.tsx index 19815475..daa7c00f 100644 --- a/demo/components/Playground.tsx +++ b/demo/components/Playground.tsx @@ -63,6 +63,7 @@ export type PlaygroundProps = { allowHTML?: boolean; settingsVisible?: boolean; initialEditor?: MarkdownEditorMode; + preserveEmptyRows?: boolean; breaks?: boolean; linkify?: boolean; linkifyTlds?: string | string[]; @@ -115,6 +116,7 @@ export const Playground = React.memo((props) => { allowHTML, breaks, linkify, + preserveEmptyRows, linkifyTlds, sanitizeHtml, prepareRawMarkup, @@ -219,6 +221,7 @@ export const Playground = React.memo((props) => { experimental: { ...experimental, directiveSyntax, + preserveEmptyRows: preserveEmptyRows, }, prepareRawMarkup: prepareRawMarkup ? (value) => '**prepare raw markup**\n\n' + value diff --git a/src/bundle/Editor.ts b/src/bundle/Editor.ts index 71e97775..84f032c7 100644 --- a/src/bundle/Editor.ts +++ b/src/bundle/Editor.ts @@ -4,6 +4,9 @@ import {EditorView as CMEditorView} from '@codemirror/view'; import {TextSelection} from 'prosemirror-state'; import {EditorView as PMEditorView} from 'prosemirror-view'; +import {TransformFn} from 'src/core/markdown/ProseMirrorTransformer'; + +import {getAutocompleteConfig} from '../../src/markup/codemirror/autocomplete'; import type {CommonEditor, MarkupString} from '../common'; import { type ActionStorage, @@ -124,6 +127,7 @@ export type EditorOptions = Pick< renderStorage: ReactRenderStorage; preset: EditorPreset; directiveSyntax: DirectiveSyntaxContext; + pmTransformers: TransformFn[]; }; /** @internal */ @@ -139,6 +143,8 @@ export class EditorImpl extends SafeEventEmitter implements EditorI #markupConfig: MarkupConfig; #escapeConfig?: EscapeConfig; #mdOptions: Readonly; + #pmTransformers: TransformFn[] = []; + #preserveEmptyRows: boolean; readonly #preset: EditorPreset; #extensions?: WysiwygEditorOptions['extensions']; @@ -248,6 +254,7 @@ export class EditorImpl extends SafeEventEmitter implements EditorI mdPreset, initialContent: this.#markup, extensions: this.#extensions, + pmTransformers: this.#pmTransformers, allowHTML: this.#mdOptions.html, linkify: this.#mdOptions.linkify, linkifyTlds: this.#mdOptions.linkifyTlds, @@ -279,7 +286,12 @@ export class EditorImpl extends SafeEventEmitter implements EditorI extensions: this.#markupConfig.extensions, disabledExtensions: this.#markupConfig.disabledExtensions, keymaps: this.#markupConfig.keymaps, - yfmLangOptions: {languageData: this.#markupConfig.languageData}, + preserveEmptyRows: this.#preserveEmptyRows, + yfmLangOptions: { + languageData: getAutocompleteConfig({ + preserveEmptyRows: this.#preserveEmptyRows, + }).concat(this.#markupConfig?.languageData || []), + }, autocompletion: this.#markupConfig.autocompletion, directiveSyntax: this.directiveSyntax, receiver: this, @@ -330,6 +342,7 @@ export class EditorImpl extends SafeEventEmitter implements EditorI this.#markup = initial.markup ?? ''; this.#preset = opts.preset ?? 'full'; + this.#pmTransformers = opts.pmTransformers; this.#mdOptions = md; this.#extensions = wysiwygConfig.extensions; this.#markupConfig = {...opts.markupConfig}; @@ -342,6 +355,7 @@ export class EditorImpl extends SafeEventEmitter implements EditorI ); this.#directiveSyntax = opts.directiveSyntax; this.#enableNewImageSizeCalculation = Boolean(experimental.enableNewImageSizeCalculation); + this.#preserveEmptyRows = experimental.preserveEmptyRows || false; this.#prepareRawMarkup = experimental.prepareRawMarkup; this.#escapeConfig = wysiwygConfig.escapeConfig; this.#beforeEditorModeChange = experimental.beforeEditorModeChange; diff --git a/src/bundle/config/action-names.ts b/src/bundle/config/action-names.ts index a4a5d2e1..cb4ab827 100644 --- a/src/bundle/config/action-names.ts +++ b/src/bundle/config/action-names.ts @@ -20,6 +20,7 @@ const names = [ 'heading4', 'heading5', 'heading6', + 'emptyRow', /** @deprecated use horizontalRule */ 'horizontalrule', 'horizontalRule', diff --git a/src/bundle/types.ts b/src/bundle/types.ts index b25ad335..a25fda69 100644 --- a/src/bundle/types.ts +++ b/src/bundle/types.ts @@ -104,6 +104,12 @@ export type MarkdownEditorExperimentalOptions = { * Default value is 'disabled'. */ directiveSyntax?: DirectiveSyntaxOption; + /** + * If we need support for empty strings + * + * @default false + */ + preserveEmptyRows?: boolean; }; export type MarkdownEditorMarkupConfig = { diff --git a/src/bundle/useMarkdownEditor.ts b/src/bundle/useMarkdownEditor.ts index 6cdb1a7c..c515b9cb 100644 --- a/src/bundle/useMarkdownEditor.ts +++ b/src/bundle/useMarkdownEditor.ts @@ -1,6 +1,7 @@ import {useLayoutEffect, useMemo} from 'react'; import type {Extension} from '../core'; +import {getPMTransformers} from '../core/markdown/ProseMirrorTransformer/getTransformers'; import {ReactRenderStorage} from '../extensions'; import {logger} from '../logger'; import {DirectiveSyntaxContext} from '../utils/directive'; @@ -33,6 +34,7 @@ export function useMarkdownEditor( } = props; const breaks = md.breaks ?? props.breaks; + const preserveEmptyRows = experimental.preserveEmptyRows; const preset: MarkdownEditorPreset = props.preset ?? 'full'; const renderStorage = new ReactRenderStorage(); const uploadFile = handlers.uploadFile ?? props.fileUploadHandler; @@ -41,6 +43,10 @@ export function useMarkdownEditor( props.needToSetDimensionsForUploadedImages; const enableNewImageSizeCalculation = experimental.enableNewImageSizeCalculation; + const pmTransformers = getPMTransformers({ + emptyRowTransformer: preserveEmptyRows, + }); + const directiveSyntax = new DirectiveSyntaxContext(experimental.directiveSyntax); const extensions: Extension = (builder) => { @@ -59,6 +65,7 @@ export function useMarkdownEditor( editor.emit('submit', null); return true; }, + preserveEmptyRows: preserveEmptyRows, placeholderOptions: wysiwygConfig.placeholderOptions, mdBreaks: breaks, fileUploadHandler: uploadFile, @@ -72,11 +79,13 @@ export function useMarkdownEditor( } } }; + return new EditorImpl({ ...props, preset, renderStorage, directiveSyntax, + pmTransformers, md: { ...md, breaks, diff --git a/src/bundle/wysiwyg-preset.ts b/src/bundle/wysiwyg-preset.ts index af43bc4a..ca2bc1bd 100644 --- a/src/bundle/wysiwyg-preset.ts +++ b/src/bundle/wysiwyg-preset.ts @@ -26,6 +26,7 @@ export type BundlePresetOptions = ExtensionsOptions & EditorModeKeymapOptions & { preset: MarkdownEditorPreset; mdBreaks?: boolean; + preserveEmptyRows?: boolean; fileUploadHandler?: FileUploadHandler; placeholderOptions?: WysiwygPlaceholderOptions; /** @@ -81,6 +82,7 @@ export const BundlePreset: ExtensionAuto = (builder, opts) ? value() : value ?? i18nPlaceholder('doc_empty'); }, + preserveEmptyRows: opts.preserveEmptyRows, ...opts.baseSchema, }, }; diff --git a/src/core/Editor.ts b/src/core/Editor.ts index 2c18e13f..a7f44344 100644 --- a/src/core/Editor.ts +++ b/src/core/Editor.ts @@ -7,6 +7,7 @@ import type {CommonEditor, ContentHandler, MarkupString} from '../common'; import type {ActionsManager} from './ActionsManager'; import {WysiwygContentHandler} from './ContentHandler'; import {ExtensionsManager} from './ExtensionsManager'; +import {TransformFn} from './markdown/ProseMirrorTransformer'; import type {ActionStorage} from './types/actions'; import type {Extension} from './types/extension'; import type {Parser} from './types/parser'; @@ -30,6 +31,7 @@ export type WysiwygEditorOptions = { mdPreset?: PresetName; allowHTML?: boolean; linkify?: boolean; + pmTransformers?: TransformFn[]; linkifyTlds?: string | string[]; escapeConfig?: EscapeConfig; /** Call on any state change (move cursor, change selection, etc...) */ @@ -74,6 +76,7 @@ export class WysiwygEditor implements CommonEditor, ActionStorage { allowHTML, mdPreset, linkify, + pmTransformers, linkifyTlds, escapeConfig, onChange, @@ -92,6 +95,7 @@ export class WysiwygEditor implements CommonEditor, ActionStorage { // "breaks" option only affects the renderer, but not the parser mdOpts: {html: allowHTML, linkify, breaks: true, preset: mdPreset}, linkifyTlds, + pmTransformers, }); const state = EditorState.create({ diff --git a/src/core/ExtensionsManager.ts b/src/core/ExtensionsManager.ts index e9c1e848..7ce5de49 100644 --- a/src/core/ExtensionsManager.ts +++ b/src/core/ExtensionsManager.ts @@ -6,6 +6,7 @@ import {ExtensionBuilder} from './ExtensionBuilder'; import {ParserTokensRegistry} from './ParserTokensRegistry'; import {SchemaSpecRegistry} from './SchemaSpecRegistry'; import {SerializerTokensRegistry} from './SerializerTokensRegistry'; +import {TransformFn} from './markdown/ProseMirrorTransformer'; import type {ActionSpec} from './types/actions'; import type { Extension, @@ -24,6 +25,7 @@ type ExtensionsManagerParams = { type ExtensionsManagerOptions = { mdOpts?: MarkdownIt.Options & {preset?: PresetName}; linkifyTlds?: string | string[]; + pmTransformers?: TransformFn[]; }; export class ExtensionsManager { @@ -38,6 +40,8 @@ export class ExtensionsManager { #nodeViewCreators = new Map NodeViewConstructor>(); #markViewCreators = new Map MarkViewConstructor>(); + #pmTransformers: TransformFn[] = []; + #mdForMarkup: MarkdownIt; #mdForText: MarkdownIt; #extensions: Extension; @@ -62,6 +66,10 @@ export class ExtensionsManager { this.#mdForText.linkify.tlds(options.linkifyTlds, true); } + if (options.pmTransformers) { + this.#pmTransformers = options.pmTransformers; + } + // TODO: add prefilled context this.#builder = new ExtensionBuilder(); } @@ -118,8 +126,16 @@ export class ExtensionsManager { this.#deps = { schema, actions: new ActionsManager(), - markupParser: this.#parserRegistry.createParser(schema, this.#mdForMarkup), - textParser: this.#parserRegistry.createParser(schema, this.#mdForText), + markupParser: this.#parserRegistry.createParser( + schema, + this.#mdForMarkup, + this.#pmTransformers, + ), + textParser: this.#parserRegistry.createParser( + schema, + this.#mdForText, + this.#pmTransformers, + ), serializer: this.#serializerRegistry.createSerializer(), }; } diff --git a/src/core/ParserTokensRegistry.ts b/src/core/ParserTokensRegistry.ts index 6adc284d..a8256363 100644 --- a/src/core/ParserTokensRegistry.ts +++ b/src/core/ParserTokensRegistry.ts @@ -2,6 +2,7 @@ import type MarkdownIt from 'markdown-it'; import type {Schema} from 'prosemirror-model'; import {MarkdownParser} from './markdown/MarkdownParser'; +import {TransformFn} from './markdown/ProseMirrorTransformer'; import type {Parser, ParserToken} from './types/parser'; export class ParserTokensRegistry { @@ -12,7 +13,7 @@ export class ParserTokensRegistry { return this; } - createParser(schema: Schema, tokenizer: MarkdownIt): Parser { - return new MarkdownParser(schema, tokenizer, this.#tokens); + createParser(schema: Schema, tokenizer: MarkdownIt, pmTransformers: TransformFn[]): Parser { + return new MarkdownParser(schema, tokenizer, this.#tokens, pmTransformers); } } diff --git a/src/core/markdown/Markdown.test.ts b/src/core/markdown/Markdown.test.ts index 42a084ec..82996bde 100644 --- a/src/core/markdown/Markdown.test.ts +++ b/src/core/markdown/Markdown.test.ts @@ -14,23 +14,28 @@ import {MarkdownSerializer} from './MarkdownSerializer'; const {schema} = builder; schema.nodes['hard_break'].spec.isBreak = true; -const parser: Parser = new MarkdownParser(schema, new MarkdownIt('commonmark'), { - paragraph: {type: 'block', name: 'paragraph'}, - heading: { - type: 'block', - name: 'heading', - getAttrs: (tok) => ({level: Number(tok.tag.slice(1))}), +const parser: Parser = new MarkdownParser( + schema, + new MarkdownIt('commonmark'), + { + paragraph: {type: 'block', name: 'paragraph'}, + heading: { + type: 'block', + name: 'heading', + getAttrs: (tok) => ({level: Number(tok.tag.slice(1))}), + }, + list_item: {type: 'block', name: 'list_item'}, + bullet_list: {type: 'block', name: 'bullet_list'}, + ordered_list: {type: 'block', name: 'ordered_list'}, + hardbreak: {type: 'node', name: 'hard_break'}, + fence: {type: 'block', name: 'code_block', noCloseToken: true}, + + em: {type: 'mark', name: 'em'}, + strong: {type: 'mark', name: 'strong'}, + code_inline: {type: 'mark', name: 'code', noCloseToken: true}, }, - list_item: {type: 'block', name: 'list_item'}, - bullet_list: {type: 'block', name: 'bullet_list'}, - ordered_list: {type: 'block', name: 'ordered_list'}, - hardbreak: {type: 'node', name: 'hard_break'}, - fence: {type: 'block', name: 'code_block', noCloseToken: true}, - - em: {type: 'mark', name: 'em'}, - strong: {type: 'mark', name: 'strong'}, - code_inline: {type: 'mark', name: 'code', noCloseToken: true}, -}); + [], +); const serializer = new MarkdownSerializer( { text: ((state, node) => { diff --git a/src/core/markdown/MarkdownParser.test.ts b/src/core/markdown/MarkdownParser.test.ts index 622e3c74..0085275d 100644 --- a/src/core/markdown/MarkdownParser.test.ts +++ b/src/core/markdown/MarkdownParser.test.ts @@ -7,11 +7,16 @@ import type {Parser} from '../types/parser'; import {MarkdownParser} from './MarkdownParser'; const md = MarkdownIt('commonmark', {html: false, breaks: true}); -const testParser: Parser = new MarkdownParser(schema, md, { - blockquote: {name: 'blockquote', type: 'block', ignore: true}, - paragraph: {type: 'block', name: 'paragraph'}, - softbreak: {type: 'node', name: 'hard_break'}, -}); +const testParser: Parser = new MarkdownParser( + schema, + md, + { + blockquote: {name: 'blockquote', type: 'block', ignore: true}, + paragraph: {type: 'block', name: 'paragraph'}, + softbreak: {type: 'node', name: 'hard_break'}, + }, + [], +); function parseWith(parser: Parser) { return (text: string, node: Node) => { diff --git a/src/core/markdown/MarkdownParser.ts b/src/core/markdown/MarkdownParser.ts index cae4fdb0..d0344a71 100644 --- a/src/core/markdown/MarkdownParser.ts +++ b/src/core/markdown/MarkdownParser.ts @@ -6,6 +6,8 @@ import {Mark, MarkType, Node, NodeType, Schema} from 'prosemirror-model'; import {logger} from '../../logger'; import type {Parser, ParserToken} from '../types/parser'; +import {ProseMirrorTransformer, TransformFn} from './ProseMirrorTransformer'; + type TokenAttrs = {[name: string]: unknown}; const openSuffix = '_open'; @@ -22,12 +24,19 @@ export class MarkdownParser implements Parser { marks: readonly Mark[]; tokens: Record; tokenizer: MarkdownIt; - - constructor(schema: Schema, tokenizer: MarkdownIt, tokens: Record) { + pmTransformers: TransformFn[]; + + constructor( + schema: Schema, + tokenizer: MarkdownIt, + tokens: Record, + pmTransformers: TransformFn[], + ) { this.schema = schema; this.marks = Mark.none; this.tokens = tokens; this.tokenizer = tokenizer; + this.pmTransformers = pmTransformers; } validateLink(url: string): boolean { @@ -69,7 +78,9 @@ export class MarkdownParser implements Parser { doc = this.closeNode(); } while (this.stack.length); - return (doc || this.schema.topNodeType.createAndFill()) as Node; + const pmTransformer = new ProseMirrorTransformer(this.pmTransformers); + + return doc ? pmTransformer.transform(doc) : this.schema.topNodeType.createAndFill()!; } finally { logger.metrics({component: 'parser', event: 'parse', duration: Date.now() - time}); } diff --git a/src/core/markdown/ProseMirrorTransformer/emptyRowTransformer.ts b/src/core/markdown/ProseMirrorTransformer/emptyRowTransformer.ts new file mode 100644 index 00000000..4f0439b9 --- /dev/null +++ b/src/core/markdown/ProseMirrorTransformer/emptyRowTransformer.ts @@ -0,0 +1,8 @@ +import {TransformFn} from './index'; + +export const transformEmptyParagraph: TransformFn = (node) => { + if (node.type !== 'paragraph') return; + if (node.content?.length !== 1) return; + if (node.content[0]?.type !== 'text') return; + if (node.content[0].text === String.fromCharCode(160)) delete node.content; +}; diff --git a/src/core/markdown/ProseMirrorTransformer/getTransformers.ts b/src/core/markdown/ProseMirrorTransformer/getTransformers.ts new file mode 100644 index 00000000..552229c7 --- /dev/null +++ b/src/core/markdown/ProseMirrorTransformer/getTransformers.ts @@ -0,0 +1,20 @@ +// TODO: add a new method to the ExtensionBuilder +import {transformEmptyParagraph} from './emptyRowTransformer'; + +import {TransformFn} from '.'; + +type GetTransformersProps = { + emptyRowTransformer?: boolean; +}; + +type GetPMTransformersType = (config: GetTransformersProps) => TransformFn[]; + +export const getPMTransformers: GetPMTransformersType = ({emptyRowTransformer}) => { + const transformers = []; + + if (emptyRowTransformer) { + transformers.push(transformEmptyParagraph); + } + + return transformers; +}; diff --git a/src/core/markdown/ProseMirrorTransformer/index.ts b/src/core/markdown/ProseMirrorTransformer/index.ts new file mode 100644 index 00000000..2b5d1ef2 --- /dev/null +++ b/src/core/markdown/ProseMirrorTransformer/index.ts @@ -0,0 +1,35 @@ +import {Node} from 'prosemirror-model'; + +type PMNodeJSON = { + type: string; + attrs?: Record; + content?: PMNodeJSON[]; + text?: string; +}; + +export type TransformFn = (node: PMNodeJSON) => void; + +export class ProseMirrorTransformer { + private readonly _transformers: TransformFn[]; + + constructor(fns: TransformFn[]) { + this._transformers = fns; + } + + transform(doc: Node): Node { + const docJSON = doc.toJSON(); + this.transformJSON(docJSON); + return Node.fromJSON(doc.type.schema, docJSON); + } + + transformJSON(node: PMNodeJSON) { + for (const fn of this._transformers) { + fn(node); + } + if (node.content) { + for (const child of node.content) { + this.transformJSON(child); + } + } + } +} diff --git a/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts b/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts index 47095856..09e9c793 100644 --- a/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts +++ b/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts @@ -16,6 +16,7 @@ export const pType = nodeTypeFactory(BaseNode.Paragraph); export type BaseSchemaSpecsOptions = { // This cannot be passed through placeholder option of BehaviorPreset because BasePreset initializes first paragraphPlaceholder?: NonNullable['content']; + preserveEmptyRows?: boolean; }; export const BaseSchemaSpecs: ExtensionAuto = (builder, opts) => { @@ -62,9 +63,31 @@ export const BaseSchemaSpecs: ExtensionAuto = (builder, : undefined, }, fromMd: {tokenSpec: {name: BaseNode.Paragraph, type: 'block'}}, - toMd: (state, node) => { - state.renderInline(node); - state.closeBlock(node); + toMd: (state, node, parent) => { + /* + An empty line is added only if there is some content in the parent element. + This is necessary in order to prevent an empty document with empty lines + */ + if (opts.preserveEmptyRows && !node.content.size) { + let isParentEmpty = true; + + for (let index = 0; index < parent.content.childCount; index++) { + const parentChild = parent.content.child(index); + if ( + parentChild.content.size !== 0 || + parentChild.type.name !== 'paragraph' + ) { + isParentEmpty = false; + } + } + + if (!isParentEmpty) { + state.write(' \n\n'); + } + } else { + state.renderInline(node); + state.closeBlock(node); + } }, })); }; diff --git a/src/i18n/empty-row/en.json b/src/i18n/empty-row/en.json new file mode 100644 index 00000000..7fd00fe8 --- /dev/null +++ b/src/i18n/empty-row/en.json @@ -0,0 +1,3 @@ +{ + "snippet.text": "Empty row" +} diff --git a/src/i18n/empty-row/index.ts b/src/i18n/empty-row/index.ts new file mode 100644 index 00000000..8637e99f --- /dev/null +++ b/src/i18n/empty-row/index.ts @@ -0,0 +1,8 @@ +import {registerKeyset} from '../i18n'; + +import en from './en.json'; +import ru from './ru.json'; + +const KEYSET = 'empty-row'; + +export const i18n = registerKeyset(KEYSET, {en, ru}); diff --git a/src/i18n/empty-row/ru.json b/src/i18n/empty-row/ru.json new file mode 100644 index 00000000..94eaf73a --- /dev/null +++ b/src/i18n/empty-row/ru.json @@ -0,0 +1,3 @@ +{ + "snippet.text": "Пустая строка" +} diff --git a/src/markup/codemirror/autocomplete/emptyRow.ts b/src/markup/codemirror/autocomplete/emptyRow.ts new file mode 100644 index 00000000..1714af5f --- /dev/null +++ b/src/markup/codemirror/autocomplete/emptyRow.ts @@ -0,0 +1,28 @@ +import {CompletionContext, CompletionResult, snippet} from '@codemirror/autocomplete'; + +import {i18n} from '../../../../src/i18n/empty-row'; + +export const emptyRowSnippetTemplate = ' \n\n'; +export const emptyRowSnippet = snippet(emptyRowSnippetTemplate); + +export const emptyRowAutocomplete = { + autocomplete: (context: CompletionContext): CompletionResult | null => { + const word = context.matchBefore(/^.*/); + + if (word?.text.startsWith('&')) { + return { + from: word.from, + options: [ + { + label: ' ', + displayLabel: i18n('snippet.text'), + type: 'text', + apply: emptyRowSnippet, + }, + ], + }; + } + + return null; + }, +}; diff --git a/src/markup/codemirror/autocomplete/index.ts b/src/markup/codemirror/autocomplete/index.ts new file mode 100644 index 00000000..d77b500e --- /dev/null +++ b/src/markup/codemirror/autocomplete/index.ts @@ -0,0 +1,19 @@ +import {mdAutocomplete} from '../yfm'; + +import {emptyRowAutocomplete} from './emptyRow'; + +type GetAutocompleteConfig = { + preserveEmptyRows?: boolean; +}; + +export const getAutocompleteConfig = ({preserveEmptyRows}: GetAutocompleteConfig) => { + const autocompleteItems = []; + + if (preserveEmptyRows) { + autocompleteItems.push(emptyRowAutocomplete); + } + + autocompleteItems.push(mdAutocomplete); + + return autocompleteItems; +}; diff --git a/src/markup/codemirror/create.ts b/src/markup/codemirror/create.ts index 0272afb8..6aa16984 100644 --- a/src/markup/codemirror/create.ts +++ b/src/markup/codemirror/create.ts @@ -21,6 +21,7 @@ import type {Receiver} from '../../utils'; import {DataTransferType, shouldSkipHtmlConversion} from '../../utils/clipboard'; import type {DirectiveSyntaxContext} from '../../utils/directive'; import { + insertEmptyRow, insertImages, insertLink, toH1, @@ -76,6 +77,7 @@ export type CreateCodemirrorParams = { yfmLangOptions?: YfmLangOptions; autocompletion?: Autocompletion; directiveSyntax: DirectiveSyntaxContext; + preserveEmptyRows: boolean; }; export function createCodemirror(params: CreateCodemirrorParams) { @@ -97,6 +99,7 @@ export function createCodemirror(params: CreateCodemirrorParams) { parseHtmlOnPaste, parseInsertedUrlAsImage, directiveSyntax, + preserveEmptyRows, } = params; const extensions: Extension[] = [gravityTheme, placeholder(placeholderContent)]; @@ -245,6 +248,14 @@ export function createCodemirror(params: CreateCodemirrorParams) { }), ); + if (preserveEmptyRows) { + extensions.push( + keymap.of([ + {key: f.toCM(A.EmptyRow)!, run: withLogger(ActionName.emptyRow, insertEmptyRow)}, + ]), + ); + } + if (params.uploadHandler) { extensions.push( FileUploadHandlerFacet.of({ diff --git a/src/markup/codemirror/yfm.ts b/src/markup/codemirror/yfm.ts index d6259cbf..ccc6f15c 100644 --- a/src/markup/codemirror/yfm.ts +++ b/src/markup/codemirror/yfm.ts @@ -90,7 +90,7 @@ export interface YfmLangOptions { languageData?: LanguageData[]; } -const mdAutocomplete: LanguageData = { +export const mdAutocomplete: LanguageData = { autocomplete: (context) => { const directiveContext = context.state.facet(DirectiveSyntaxFacet); diff --git a/src/markup/commands/emptyRow.ts b/src/markup/commands/emptyRow.ts new file mode 100644 index 00000000..438f3bd5 --- /dev/null +++ b/src/markup/commands/emptyRow.ts @@ -0,0 +1,53 @@ +import {EditorState, Line, StateCommand} from '@codemirror/state'; + +export const insertEmptyRow: StateCommand = ({state, dispatch}) => { + const emptyRowMarkup = ' '; + + const tr = () => { + const selrange = state.selection.main; + const {before, after, selection} = getBlockExtraLineBreaks( + state, + state.doc.lineAt(selrange.from), + ); + + const insert = + state.lineBreak.repeat(before) + emptyRowMarkup + state.lineBreak.repeat(after); + + const from = state.doc.lineAt(selrange.to).to; + const selAnchor = from + insert.length + selection; + + return {changes: {from, insert}, selection: {anchor: selAnchor}}; + }; + + dispatch(state.update(tr())); + return true; +}; + +function getBlockExtraLineBreaks(state: EditorState, line: Line) { + let before = 0; + let after = 0; + let selection = 2; + + if (line.text) { + before = 2; + } else if (line.number > 1 && state.doc.line(line.number - 1).text) { + before = 1; + } + + if (line.number + 1 <= state.doc.lines && state.doc.line(line.number + 1).text) { + after = 1; + selection = 1; + } else if ( + line.number + 1 <= state.doc.lines && + !state.doc.line(line.number + 1).text && + line.number + 2 > state.doc.lines + ) { + after = 1; + selection = 1; + } else if (line.number === state.doc.lines) { + after = 2; + selection = 0; + } + + return {before, after, selection}; +} diff --git a/src/markup/commands/index.ts b/src/markup/commands/index.ts index 08642111..99ec773a 100644 --- a/src/markup/commands/index.ts +++ b/src/markup/commands/index.ts @@ -8,3 +8,4 @@ export * from './lists'; export * from './marks'; export * from './math'; export * from './yfm'; +export * from './emptyRow'; diff --git a/src/shortcuts/const.ts b/src/shortcuts/const.ts index 1b4ffa79..46dbf443 100644 --- a/src/shortcuts/const.ts +++ b/src/shortcuts/const.ts @@ -38,6 +38,8 @@ export enum Action { Heading5 = 'h5', Heading6 = 'h6', + EmptyRow = 'EmptyRow', + BulletList = 'ulist', OrderedList = 'olist', diff --git a/src/shortcuts/default.ts b/src/shortcuts/default.ts index 104416e5..3c45a369 100644 --- a/src/shortcuts/default.ts +++ b/src/shortcuts/default.ts @@ -26,6 +26,8 @@ formatter .set(A.Heading5, {pc: [MK.Ctrl, MK.Shift, '5'], mac: [MK.Cmd, MK.Option, '5']}) .set(A.Heading6, {pc: [MK.Ctrl, MK.Shift, '6'], mac: [MK.Cmd, MK.Option, '6']}) + .set(A.EmptyRow, {pc: [MK.Ctrl, MK.Shift, K.Enter], mac: [MK.Cmd, MK.Shift, K.Enter]}) + .set(A.BulletList, [MK.Mod, MK.Shift, 'l']) .set(A.OrderedList, [MK.Mod, MK.Shift, 'm']) From c24f2e0258ee5dab8c5ca08a56562aa69b044569 Mon Sep 17 00:00:00 2001 From: Yuriy Demidov Date: Mon, 23 Dec 2024 19:25:26 +0300 Subject: [PATCH 18/19] chore(demo): added new story; rewritten some other stories (#536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For `demo` only: 1. Added PlaygroundLayout component 2. Added `useMarkdownEditorValue()` hook 3. Rewritten `CustomCSSVariables`, `EditorInEditor`, `EscapeConfig`, `Ghost`, `GPT` stories 4. Added new story – `PreserveEmptyRows` --- demo/components/Playground.tsx | 203 ++++++++---------- demo/components/PlaygroundLayout.tsx | 68 ++++++ demo/components/PlaygroundMini.tsx | 1 - demo/defaults/excluded-controls.ts | 1 - demo/hooks/useMarkdownEditorValue.ts | 18 ++ demo/stories/css-variables/CSSVariables.tsx | 30 ++- .../editor-in-editor/EditorInEditor.tsx | 28 +-- .../escape-config/EscapeConfig.stories.tsx | 4 +- demo/stories/escape-config/EscapeConfig.tsx | 47 ++-- .../empty-row/EmptyRow.stories.tsx | 15 ++ .../experiments/empty-row/EmptyRows.tsx | 83 +++++++ demo/stories/ghost/Ghost.tsx | 30 ++- demo/stories/gpt/GPT.tsx | 66 +++--- package-lock.json | 4 +- 14 files changed, 400 insertions(+), 198 deletions(-) create mode 100644 demo/components/PlaygroundLayout.tsx create mode 100644 demo/hooks/useMarkdownEditorValue.ts create mode 100644 demo/stories/experiments/empty-row/EmptyRow.stories.tsx create mode 100644 demo/stories/experiments/empty-row/EmptyRows.tsx diff --git a/demo/components/Playground.tsx b/demo/components/Playground.tsx index daa7c00f..192771c4 100644 --- a/demo/components/Playground.tsx +++ b/demo/components/Playground.tsx @@ -6,7 +6,6 @@ import {toaster} from '@gravity-ui/uikit/toaster-singleton-react-18'; import { type DirectiveSyntaxValue, - type EscapeConfig, type FileUploadHandler, type MarkdownEditorMode, MarkdownEditorView, @@ -30,21 +29,15 @@ import {YfmHtmlBlock} from '../../src/extensions/additional/YfmHtmlBlock'; import {getSanitizeYfmHtmlBlock} from '../../src/extensions/additional/YfmHtmlBlock/utils'; import type {CodeEditor} from '../../src/markup'; import {ToolbarsPreset} from '../../src/modules/toolbars/types'; -import {VERSION} from '../../src/version'; import {getPlugins} from '../defaults/md-plugins'; import useYfmHtmlBlockStyles from '../hooks/useYfmHtmlBlockStyles'; -import {block} from '../utils/cn'; import {randomDelay} from '../utils/delay'; import {parseInsertedUrlAsImage} from '../utils/imageUrl'; import {debouncedUpdateLocation as updateLocation} from '../utils/location'; -import {WysiwygSelection} from './PMSelection'; -import {WysiwygDevTools} from './ProseMirrorDevTools'; +import {PlaygroundLayout, b} from './PlaygroundLayout'; import {SplitModePreview} from './SplitModePreview'; -import './Playground.scss'; - -const b = block('playground'); const fileUploadHandler: FileUploadHandler = async (file) => { console.info('[Playground] Uploading file: ' + file.name); await randomDelay(1000, 3000); @@ -76,7 +69,6 @@ export type PlaygroundProps = { renderPreviewDefined?: boolean; height?: CSSProperties['height']; markupConfigExtensions?: Extension[]; - escapeConfig?: EscapeConfig; wysiwygCommandMenuConfig?: wysiwygToolbarConfigs.WToolbarItemData[]; markupToolbarConfig?: ToolbarGroupData[]; toolbarsPreset?: ToolbarsPreset; @@ -132,7 +124,6 @@ export const Playground = React.memo((props) => { markupConfigExtensions, markupToolbarConfig, placeholderOptions, - escapeConfig, enableSubmitInPreview, hidePreviewAfterSubmit, needToSetDimensionsForUploadedImages, @@ -167,7 +158,6 @@ export const Playground = React.memo((props) => { { preset: 'full', wysiwygConfig: { - escapeConfig, placeholderOptions: placeholderOptions, extensions: (builder) => { builder @@ -304,107 +294,96 @@ export const Playground = React.memo((props) => { }, [mdEditor]); return ( -
-
- Markdown Editor Playground - {VERSION} -
-
- - isEmpty: {String(mdEditor.isEmpty())} - - } - > - { - mdEditor.clear(); - mdEditor.focus(); - }} - /> - { - mdEditor.append('> append'); - mdEditor.focus(); - }} - /> - { - mdEditor.prepend('> prepend'); - mdEditor.focus(); - }} - /> - { - mdEditor.replace('> replace'); - mdEditor.focus(); - }} - /> - { - mdEditor.moveCursor('start'); - mdEditor.focus(); - }} - /> - { - mdEditor.moveCursor('end'); - mdEditor.focus(); - }} - /> - { - mdEditor.moveCursor({line: 115}); - mdEditor.focus(); - }} - /> - - {mdEditor.currentMode === 'markup' && ( - { - if (typeof line !== 'number' || Number.isNaN(line)) return; - mdEditor.moveCursor({line}); - mdEditor.focus(); - }} - /> - )} -
-
- -
- - - -
-
- -
- -
- {editorMode === 'wysiwyg' &&
{mdRaw}
} -
-
+ ( + + )} + actions={() => ( + <> + + isEmpty: {String(mdEditor.isEmpty())} + + } + > + { + mdEditor.clear(); + mdEditor.focus(); + }} + /> + { + mdEditor.append('> append'); + mdEditor.focus(); + }} + /> + { + mdEditor.prepend('> prepend'); + mdEditor.focus(); + }} + /> + { + mdEditor.replace('> replace'); + mdEditor.focus(); + }} + /> + { + mdEditor.moveCursor('start'); + mdEditor.focus(); + }} + /> + { + mdEditor.moveCursor('end'); + mdEditor.focus(); + }} + /> + { + mdEditor.moveCursor({line: 115}); + mdEditor.focus(); + }} + /> + + {mdEditor.currentMode === 'markup' && ( + { + if (typeof line !== 'number' || Number.isNaN(line)) return; + mdEditor.moveCursor({line}); + mdEditor.focus(); + }} + /> + )} + + )} + /> ); }); diff --git a/demo/components/PlaygroundLayout.tsx b/demo/components/PlaygroundLayout.tsx new file mode 100644 index 00000000..065d8517 --- /dev/null +++ b/demo/components/PlaygroundLayout.tsx @@ -0,0 +1,68 @@ +import React, {useEffect} from 'react'; + +import {useUpdate} from 'react-use'; + +import type {MarkdownEditorInstance} from '../../src'; +import {VERSION} from '../../src/version'; +import {useMarkdownEditorValue} from '../hooks/useMarkdownEditorValue'; +import {block} from '../utils/cn'; + +import {WysiwygSelection} from './PMSelection'; +import {WysiwygDevTools} from './ProseMirrorDevTools'; + +import './Playground.scss'; + +export const b = block('playground'); + +export type RenderFn = (props: {className?: string}) => React.ReactNode; + +export type PlaygroundLayoutProps = { + title?: string; + editor: MarkdownEditorInstance; + view: RenderFn; + viewHeight?: React.CSSProperties['height']; + actions?: RenderFn; + style?: React.CSSProperties; +}; + +export const PlaygroundLayout: React.FC = function PlaygroundLayout(props) { + const {editor} = props; + + const forceRender = useUpdate(); + const mdMarkup = useMarkdownEditorValue(editor); + + useEffect(() => { + editor.on('change-editor-mode', forceRender); + return () => { + editor.off('change-editor-mode', forceRender); + }; + }, [editor, forceRender]); + + return ( +
+
+ {props.title ?? 'Markdown Editor Playground'} + {VERSION} +
+ +
{props.actions?.({})}
+ +
+ + +
+ {props.view({className: b('editor-view')})} + + + +
+
+ +
+ +
+ {editor.currentMode === 'wysiwyg' &&
{mdMarkup}
} +
+
+ ); +}; diff --git a/demo/components/PlaygroundMini.tsx b/demo/components/PlaygroundMini.tsx index 059c5bb2..f27bd57a 100644 --- a/demo/components/PlaygroundMini.tsx +++ b/demo/components/PlaygroundMini.tsx @@ -22,7 +22,6 @@ export type PlaygroundMiniProps = Pick< | 'initial' | 'onChangeEditorType' | 'onChangeSplitModeEnabled' - | 'escapeConfig' | 'directiveSyntax' > & {withDefaultInitialContent?: boolean}; diff --git a/demo/defaults/excluded-controls.ts b/demo/defaults/excluded-controls.ts index e0e4e420..00948558 100644 --- a/demo/defaults/excluded-controls.ts +++ b/demo/defaults/excluded-controls.ts @@ -3,5 +3,4 @@ export const excludedControls = [ 'withDefaultInitialContent', 'onChangeEditorType', 'onChangeSplitModeEnabled', - 'escapeConfig', ]; diff --git a/demo/hooks/useMarkdownEditorValue.ts b/demo/hooks/useMarkdownEditorValue.ts new file mode 100644 index 00000000..15cea8b9 --- /dev/null +++ b/demo/hooks/useMarkdownEditorValue.ts @@ -0,0 +1,18 @@ +import {useEffect, useState} from 'react'; + +import {type MarkdownEditorInstance, type MarkupString, useDebounce} from '../../src'; + +export function useMarkdownEditorValue(editor: MarkdownEditorInstance, delay = 500): MarkupString { + const [value, setValue] = useState(() => editor.getValue()); + + const fn = useDebounce(() => setValue(editor.getValue()), delay); + + useEffect(() => { + editor.on('change', fn); + return () => { + editor.off('change', fn); + }; + }, [editor, fn]); + + return value; +} diff --git a/demo/stories/css-variables/CSSVariables.tsx b/demo/stories/css-variables/CSSVariables.tsx index a3a255bc..64c83840 100644 --- a/demo/stories/css-variables/CSSVariables.tsx +++ b/demo/stories/css-variables/CSSVariables.tsx @@ -1,17 +1,29 @@ import React from 'react'; -import {PlaygroundMini} from '../../components/PlaygroundMini'; +import {toaster} from '@gravity-ui/uikit/toaster-singleton-react-18'; + +import {MarkdownEditorView, useMarkdownEditor} from '../../../src'; +import {PlaygroundLayout} from '../../components/PlaygroundLayout'; +import {markup} from '../../defaults/content'; export const CustomCSSVariablesDemo = React.memo((styles) => { + const editor = useMarkdownEditor({initial: {markup}}); + return ( -
- -
+ ( + + )} + /> ); }); diff --git a/demo/stories/editor-in-editor/EditorInEditor.tsx b/demo/stories/editor-in-editor/EditorInEditor.tsx index 51b4f40b..096d2ee2 100644 --- a/demo/stories/editor-in-editor/EditorInEditor.tsx +++ b/demo/stories/editor-in-editor/EditorInEditor.tsx @@ -3,8 +3,7 @@ import React, {FC, useEffect} from 'react'; import {toaster} from '@gravity-ui/uikit/toaster-singleton-react-18'; import {BaseNode, MarkdownEditorView, useMarkdownEditor} from '../../../src'; -import {VERSION} from '../../../src/version'; -import {block} from '../../utils/cn'; +import {PlaygroundLayout} from '../../components/PlaygroundLayout'; import { EditorInEditorAttr, @@ -12,10 +11,8 @@ import { EditorInEditor as extension, } from './EditorInEditorExtension'; -const b = block('playground'); - export const EditorInEditor: FC = () => { - const mdEditor = useMarkdownEditor({ + const editor = useMarkdownEditor({ initialEditorMode: 'wysiwyg', initialToolbarVisible: true, allowHTML: false, @@ -28,7 +25,7 @@ export const EditorInEditor: FC = () => { }); useEffect(() => { - const view = mdEditor._wysiwygView; + const view = editor._wysiwygView; if (view) { const {schema} = view.state; const tr = view.state.tr; @@ -44,21 +41,18 @@ export const EditorInEditor: FC = () => { }, []); return ( -
-
- Editor In Editor Playground - {VERSION} -
-
-
+ ( -
-
+ )} + /> ); }; diff --git a/demo/stories/escape-config/EscapeConfig.stories.tsx b/demo/stories/escape-config/EscapeConfig.stories.tsx index 77d06423..e91b6384 100644 --- a/demo/stories/escape-config/EscapeConfig.stories.tsx +++ b/demo/stories/escape-config/EscapeConfig.stories.tsx @@ -7,11 +7,9 @@ Story.storyName = 'Escape config'; export default { args: { - initialEditor: 'wysiwyg', commonEscapeRegexp: '^$', startOfLineEscapeRegexp: '^$', - withDefaultInitialContent: true, }, component, - title: 'Experiments / Escape config', + title: 'Settings / Wysiwyg / Escape config', }; diff --git a/demo/stories/escape-config/EscapeConfig.tsx b/demo/stories/escape-config/EscapeConfig.tsx index a7d1b00f..a48ca4e7 100644 --- a/demo/stories/escape-config/EscapeConfig.tsx +++ b/demo/stories/escape-config/EscapeConfig.tsx @@ -1,27 +1,46 @@ -import React, {FC} from 'react'; +import React from 'react'; -import {PlaygroundMini, PlaygroundMiniProps} from '../../components/PlaygroundMini'; +import {toaster} from '@gravity-ui/uikit/toaster-singleton-react-18'; -export type EscapeConfigProps = Pick< - PlaygroundMiniProps, - 'initialEditor' | 'withDefaultInitialContent' -> & { +import {MarkdownEditorView, useMarkdownEditor} from '../../../src'; +import {PlaygroundLayout} from '../../components/PlaygroundLayout'; +import {markup} from '../../defaults/content'; + +export type EscapeConfigProps = { commonEscapeRegexp: string; startOfLineEscapeRegexp: string; }; -export const EscapeConfig: FC = ({ +export const EscapeConfig: React.FC = ({ startOfLineEscapeRegexp, commonEscapeRegexp, - ...props }) => { + const editor = useMarkdownEditor( + { + initial: {markup}, + wysiwygConfig: { + escapeConfig: { + commonEscape: new RegExp(commonEscapeRegexp), + startOfLineEscape: new RegExp(startOfLineEscapeRegexp), + }, + }, + }, + [commonEscapeRegexp, startOfLineEscapeRegexp], + ); + return ( - ( + + )} /> ); }; diff --git a/demo/stories/experiments/empty-row/EmptyRow.stories.tsx b/demo/stories/experiments/empty-row/EmptyRow.stories.tsx new file mode 100644 index 00000000..c24a6735 --- /dev/null +++ b/demo/stories/experiments/empty-row/EmptyRow.stories.tsx @@ -0,0 +1,15 @@ +import type {StoryObj} from '@storybook/react'; + +import {PreserveEmptyRowsDemo as component} from './EmptyRows'; + +export const Story: StoryObj = { + args: { + preserveEmptyRows: true, + }, +}; +Story.storyName = 'Preserve Empty Rows'; + +export default { + title: 'Experiments', + component, +}; diff --git a/demo/stories/experiments/empty-row/EmptyRows.tsx b/demo/stories/experiments/empty-row/EmptyRows.tsx new file mode 100644 index 00000000..2a2e56e9 --- /dev/null +++ b/demo/stories/experiments/empty-row/EmptyRows.tsx @@ -0,0 +1,83 @@ +import React, {useCallback, useLayoutEffect, useState} from 'react'; + +import {toaster} from '@gravity-ui/uikit/toaster-singleton-react-18'; + +import {MarkdownEditorView, type RenderPreview, useMarkdownEditor} from '../../../../src'; +import {PlaygroundLayout} from '../../../components/PlaygroundLayout'; +import {SplitModePreview} from '../../../components/SplitModePreview'; +import {plugins} from '../../../defaults/md-plugins'; +import {useMarkdownEditorValue} from '../../../hooks/useMarkdownEditorValue'; + +const initialMarkup = ` +  + +  + +  + +Example of usage empty row experiment + +  + +  + +  +`; + +type PreserveEmptyRowsDemoProps = { + preserveEmptyRows: boolean; +}; + +export const PreserveEmptyRowsDemo = React.memo((props) => { + const {preserveEmptyRows} = props; + + const [mdMarkup, setMdMarkup] = useState(initialMarkup); + + const renderPreview = useCallback( + ({getValue, md}) => ( + + ), + [], + ); + + const editor = useMarkdownEditor( + { + initial: {markup: mdMarkup}, + experimental: {preserveEmptyRows}, + markupConfig: {renderPreview}, + }, + [preserveEmptyRows], + ); + + // for preserve edited content + const value = useMarkdownEditorValue(editor); + useLayoutEffect(() => { + setMdMarkup(value); + }, [value]); + + return ( + ( + + )} + /> + ); +}); + +PreserveEmptyRowsDemo.displayName = 'PreserveEmptyRows'; diff --git a/demo/stories/ghost/Ghost.tsx b/demo/stories/ghost/Ghost.tsx index 0cc2daca..3f45c4a2 100644 --- a/demo/stories/ghost/Ghost.tsx +++ b/demo/stories/ghost/Ghost.tsx @@ -1,9 +1,10 @@ import React from 'react'; +import {toaster} from '@gravity-ui/uikit/toaster-singleton-react-18'; import cloneDeep from 'lodash/cloneDeep'; -import {logger, markupToolbarConfigs} from '../../../src'; -import {Playground} from '../../components/Playground'; +import {MarkdownEditorView, logger, markupToolbarConfigs, useMarkdownEditor} from '../../../src'; +import {PlaygroundLayout} from '../../components/PlaygroundLayout'; import {initialMdContent} from './content'; import {ghostPopupExtension, ghostPopupToolbarItem} from './ghostExtension'; @@ -15,17 +16,26 @@ logger.setLogger({ }); const mToolbarConfig = cloneDeep(markupToolbarConfigs.mToolbarConfig); - -mToolbarConfig[2].unshift(ghostPopupToolbarItem); +mToolbarConfig.unshift([ghostPopupToolbarItem]); export const Ghost = () => { + const editor = useMarkdownEditor({ + initial: {markup: initialMdContent, mode: 'markup'}, + markupConfig: {extensions: [ghostPopupExtension]}, + }); + return ( - ( + + )} /> ); }; diff --git a/demo/stories/gpt/GPT.tsx b/demo/stories/gpt/GPT.tsx index 680d9a95..9299902b 100644 --- a/demo/stories/gpt/GPT.tsx +++ b/demo/stories/gpt/GPT.tsx @@ -1,50 +1,37 @@ import React, {useState} from 'react'; +import {toaster} from '@gravity-ui/uikit/toaster-singleton-react-18'; import cloneDeep from 'lodash/cloneDeep'; import { + MarkdownEditorView, gptExtension, logger, mGptExtension, mGptToolbarItem, markupToolbarConfigs, + useMarkdownEditor, wGptItemData, wysiwygToolbarConfigs, } from '../../../src'; -import {Playground} from '../../components/Playground'; +import {PlaygroundLayout} from '../../components/PlaygroundLayout'; import {initialMdContent} from './content'; import {gptWidgetProps} from './gptWidgetOptions'; -const wToolbarConfig = cloneDeep(wysiwygToolbarConfigs.wToolbarConfig); -wToolbarConfig.unshift([wGptItemData]); -wToolbarConfig.push([ - wysiwygToolbarConfigs.wMermaidItemData, - wysiwygToolbarConfigs.wYfmHtmlBlockItemData, -]); - logger.setLogger({ metrics: console.info, action: (data) => console.info(`Action: ${data.action}`, data), ...console, }); -const wCommandMenuConfig = wysiwygToolbarConfigs.wCommandMenuConfig.concat( - wysiwygToolbarConfigs.wMathInlineItemData, - wysiwygToolbarConfigs.wMathBlockItemData, - wysiwygToolbarConfigs.wMermaidItemData, - wysiwygToolbarConfigs.wYfmHtmlBlockItemData, -); +const wToolbarConfig = cloneDeep(wysiwygToolbarConfigs.wToolbarConfig); +wToolbarConfig.unshift([wGptItemData]); +const wCommandMenuConfig = cloneDeep(wysiwygToolbarConfigs.wCommandMenuConfig); wCommandMenuConfig.unshift(wGptItemData); const mToolbarConfig = cloneDeep(markupToolbarConfigs.mToolbarConfig); - -mToolbarConfig.push([ - markupToolbarConfigs.mMermaidButton, - markupToolbarConfigs.mYfmHtmlBlockButton, -]); - mToolbarConfig.unshift([mGptToolbarItem]); export const GPT = React.memo(() => { @@ -60,16 +47,37 @@ export const GPT = React.memo(() => { const markupExtension = mGptExtension(gptExtensionProps); const wSelectionMenuConfig = [[wGptItemData], ...wysiwygToolbarConfigs.wSelectionMenuConfig]; + const editor = useMarkdownEditor({ + initial: {markup: initialMdContent}, + markupConfig: {extensions: markupExtension}, + wysiwygConfig: { + extensions: (builder) => builder.use(gptExtension, gptExtensionProps), + extensionOptions: { + commandMenu: { + actions: wCommandMenuConfig, + }, + selectionContext: { + config: wSelectionMenuConfig, + }, + }, + }, + }); + return ( - builder.use(gptExtension, gptExtensionProps)} - wysiwygCommandMenuConfig={wCommandMenuConfig} - extensionOptions={{selectionContext: {config: wSelectionMenuConfig}}} - wysiwygToolbarConfig={wToolbarConfig} - markupConfigExtensions={markupExtension} - markupToolbarConfig={mToolbarConfig} + ( + + )} /> ); }); diff --git a/package-lock.json b/package-lock.json index 64278275..131f3ccb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -132,8 +132,8 @@ "lodash": "^4.17.20", "lowlight": "^3.0.0", "markdown-it": "^13.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@diplodoc/folding-headings-extension": { From 617a1ccf50c83e4deddc495162c86023f9be4760 Mon Sep 17 00:00:00 2001 From: Yuriy Demidov Date: Tue, 24 Dec 2024 12:20:57 +0300 Subject: [PATCH 19/19] fix: disable escaping when serializing content for code_block (#537) --- src/core/markdown/MarkdownSerializer.js | 2 +- src/extensions/markdown/CodeBlock/commands.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/markdown/MarkdownSerializer.js b/src/core/markdown/MarkdownSerializer.js index c4762b61..d9926549 100644 --- a/src/core/markdown/MarkdownSerializer.js +++ b/src/core/markdown/MarkdownSerializer.js @@ -165,7 +165,7 @@ export class MarkdownSerializerState { const startOfLine = this.atBlank() || this.closed; this.write(); let text = lines[i]; - if (escape !== false) text = this.esc(text, startOfLine) + if (escape !== false && this.options.escape !== false) text = this.esc(text, startOfLine) if (this.escapeWhitespace) text = this.escWhitespace(text); this.out += text if (i != lines.length - 1) this.out += '\n'; diff --git a/src/extensions/markdown/CodeBlock/commands.ts b/src/extensions/markdown/CodeBlock/commands.ts index ce9696ef..7ed1ce0d 100644 --- a/src/extensions/markdown/CodeBlock/commands.ts +++ b/src/extensions/markdown/CodeBlock/commands.ts @@ -28,7 +28,7 @@ export const setCodeBlockType = if (!setBlockType(nodeType)(state)) return false; if (dispatch) { - const markup = serializer.serialize(state.selection.content().content); + const markup = serializer.serialize(state.selection.content().content, {escape: false}); dispatch( state.tr.replaceSelectionWith( nodeType.createAndFill({}, markup ? state.schema.text(markup) : null)!,