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'])