From 1c12e200ac446ead558bdfc864452d89017039ed Mon Sep 17 00:00:00 2001 From: Mikhail Pavlovich Date: Thu, 28 Nov 2024 18:26:26 +0300 Subject: [PATCH 01/15] added support for an empty string --- demo/components/Playground.tsx | 5 ++ src/bundle/Editor.ts | 1 + src/bundle/types.ts | 1 + src/bundle/useMarkdownEditor.ts | 2 + src/bundle/wysiwyg-preset.ts | 2 + src/core/Editor.ts | 4 +- src/core/ExtensionsManager.ts | 19 ++++- src/core/ParserTokensRegistry.ts | 4 +- src/core/markdown/Markdown.test.ts | 37 +++++---- src/core/markdown/MarkdownParser.test.ts | 15 ++-- src/core/markdown/MarkdownParser.ts | 22 +++++- src/core/markdown/ProseMirrorTransformer.ts | 44 +++++++++++ src/core/markdown/emptyRowParser/constants.ts | 79 +++++++++++++++++++ .../markdown/emptyRowParser/emptyRowParser.ts | 74 +++++++++++++++++ src/core/markdown/emptyRowParser/utils.ts | 79 +++++++++++++++++++ .../base/BaseSchema/BaseSchemaSpecs/index.ts | 9 ++- 16 files changed, 365 insertions(+), 32 deletions(-) create mode 100644 src/core/markdown/ProseMirrorTransformer.ts create mode 100644 src/core/markdown/emptyRowParser/constants.ts create mode 100644 src/core/markdown/emptyRowParser/emptyRowParser.ts create mode 100644 src/core/markdown/emptyRowParser/utils.ts diff --git a/demo/components/Playground.tsx b/demo/components/Playground.tsx index 3492b81f..0a84a6ef 100644 --- a/demo/components/Playground.tsx +++ b/demo/components/Playground.tsx @@ -76,6 +76,7 @@ export type PlaygroundProps = { allowHTML?: boolean; settingsVisible?: boolean; initialEditor?: MarkdownEditorMode; + allowEmptyRows?: boolean; breaks?: boolean; linkify?: boolean; linkifyTlds?: string | string[]; @@ -126,6 +127,7 @@ export const Playground = React.memo((props) => { allowHTML, breaks, linkify, + allowEmptyRows, linkifyTlds, sanitizeHtml, prepareRawMarkup, @@ -189,6 +191,9 @@ export const Playground = React.memo((props) => { ...experimental, directiveSyntax, }, + md: { + allowEmptyRows: allowEmptyRows, + }, prepareRawMarkup: prepareRawMarkup ? (value) => '**prepare raw markup**\n\n' + value : undefined, diff --git a/src/bundle/Editor.ts b/src/bundle/Editor.ts index 71e97775..a901031a 100644 --- a/src/bundle/Editor.ts +++ b/src/bundle/Editor.ts @@ -248,6 +248,7 @@ export class EditorImpl extends SafeEventEmitter implements EditorI mdPreset, initialContent: this.#markup, extensions: this.#extensions, + allowEmptyRows: this.#mdOptions.allowEmptyRows, allowHTML: this.#mdOptions.html, linkify: this.#mdOptions.linkify, linkifyTlds: this.#mdOptions.linkifyTlds, diff --git a/src/bundle/types.ts b/src/bundle/types.ts index 34d17136..344a9a86 100644 --- a/src/bundle/types.ts +++ b/src/bundle/types.ts @@ -28,6 +28,7 @@ export type ParseInsertedUrlAsImage = (text: string) => {imageUrl: string; title export type MarkdownEditorMdOptions = { html?: boolean; + allowEmptyRows?: boolean; breaks?: boolean; linkify?: boolean; linkifyTlds?: string | string[]; diff --git a/src/bundle/useMarkdownEditor.ts b/src/bundle/useMarkdownEditor.ts index b44cb463..0a29263f 100644 --- a/src/bundle/useMarkdownEditor.ts +++ b/src/bundle/useMarkdownEditor.ts @@ -33,6 +33,7 @@ export function useMarkdownEditor( } = props; const breaks = md.breaks ?? props.breaks; + const allowEmptyRows = md.allowEmptyRows; const preset: MarkdownEditorPreset = props.preset ?? 'full'; const renderStorage = new ReactRenderStorage(); const uploadFile = handlers.uploadFile ?? props.fileUploadHandler; @@ -59,6 +60,7 @@ export function useMarkdownEditor( editor.emit('submit', null); return true; }, + allowEmptyRows: allowEmptyRows, mdBreaks: breaks, fileUploadHandler: uploadFile, needToSetDimensionsForUploadedImages, diff --git a/src/bundle/wysiwyg-preset.ts b/src/bundle/wysiwyg-preset.ts index 42874148..19e8f816 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; + allowEmptyRows?: boolean; fileUploadHandler?: FileUploadHandler; /** * If we need to set dimensions for uploaded images @@ -67,6 +68,7 @@ export const BundlePreset: ExtensionAuto = (builder, opts) !node.text && parent?.type.name === BaseNode.Doc && parent.childCount === 1; return isDocEmpty ? i18nPlaceholder('doc_empty') : null; }, + allowEmptyRows: opts.allowEmptyRows, ...opts.baseSchema, }, }; diff --git a/src/core/Editor.ts b/src/core/Editor.ts index 2c18e13f..0473f5b3 100644 --- a/src/core/Editor.ts +++ b/src/core/Editor.ts @@ -30,6 +30,7 @@ export type WysiwygEditorOptions = { mdPreset?: PresetName; allowHTML?: boolean; linkify?: boolean; + allowEmptyRows?: boolean; linkifyTlds?: string | string[]; escapeConfig?: EscapeConfig; /** Call on any state change (move cursor, change selection, etc...) */ @@ -74,6 +75,7 @@ export class WysiwygEditor implements CommonEditor, ActionStorage { allowHTML, mdPreset, linkify, + allowEmptyRows, linkifyTlds, escapeConfig, onChange, @@ -90,7 +92,7 @@ export class WysiwygEditor implements CommonEditor, ActionStorage { actions, } = ExtensionsManager.process(extensions, { // "breaks" option only affects the renderer, but not the parser - mdOpts: {html: allowHTML, linkify, breaks: true, preset: mdPreset}, + mdOpts: {allowEmptyRows, html: allowHTML, linkify, breaks: true, preset: mdPreset}, linkifyTlds, }); diff --git a/src/core/ExtensionsManager.ts b/src/core/ExtensionsManager.ts index e9c1e848..b8f700b6 100644 --- a/src/core/ExtensionsManager.ts +++ b/src/core/ExtensionsManager.ts @@ -22,7 +22,7 @@ type ExtensionsManagerParams = { }; type ExtensionsManagerOptions = { - mdOpts?: MarkdownIt.Options & {preset?: PresetName}; + mdOpts?: MarkdownIt.Options & {preset?: PresetName; allowEmptyRows?: boolean}; linkifyTlds?: string | string[]; }; @@ -49,6 +49,7 @@ export class ExtensionsManager { #actions: Record = {}; #nodeViews: Record = {}; #markViews: Record = {}; + #allowEmptyRows = false; constructor({extensions, options = {}}: ExtensionsManagerParams) { this.#extensions = extensions; @@ -62,6 +63,10 @@ export class ExtensionsManager { this.#mdForText.linkify.tlds(options.linkifyTlds, true); } + if (options.mdOpts?.allowEmptyRows) { + this.#allowEmptyRows = options.mdOpts?.allowEmptyRows; + } + // TODO: add prefilled context this.#builder = new ExtensionBuilder(); } @@ -118,8 +123,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.#allowEmptyRows, + ), + textParser: this.#parserRegistry.createParser( + schema, + this.#mdForText, + this.#allowEmptyRows, + ), serializer: this.#serializerRegistry.createSerializer(), }; } diff --git a/src/core/ParserTokensRegistry.ts b/src/core/ParserTokensRegistry.ts index 6adc284d..c024c6f8 100644 --- a/src/core/ParserTokensRegistry.ts +++ b/src/core/ParserTokensRegistry.ts @@ -12,7 +12,7 @@ export class ParserTokensRegistry { return this; } - createParser(schema: Schema, tokenizer: MarkdownIt): Parser { - return new MarkdownParser(schema, tokenizer, this.#tokens); + createParser(schema: Schema, tokenizer: MarkdownIt, allowEmptyRows: boolean): Parser { + return new MarkdownParser(schema, tokenizer, this.#tokens, allowEmptyRows); } } diff --git a/src/core/markdown/Markdown.test.ts b/src/core/markdown/Markdown.test.ts index 42a084ec..20bd079e 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}, -}); + false, +); 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..31f34245 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'}, + }, + false, +); 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..f5c12c8f 100644 --- a/src/core/markdown/MarkdownParser.ts +++ b/src/core/markdown/MarkdownParser.ts @@ -6,6 +6,11 @@ import {Mark, MarkType, Node, NodeType, Schema} from 'prosemirror-model'; import {logger} from '../../logger'; import type {Parser, ParserToken} from '../types/parser'; +import {pmTransformer} from './ProseMirrorTransformer'; + +// for a solution with map +// import {parseEmptyRow} from './emptyRowParser/emptyRowParser'; + type TokenAttrs = {[name: string]: unknown}; const openSuffix = '_open'; @@ -22,12 +27,19 @@ export class MarkdownParser implements Parser { marks: readonly Mark[]; tokens: Record; tokenizer: MarkdownIt; - - constructor(schema: Schema, tokenizer: MarkdownIt, tokens: Record) { + allowEmptyRow: boolean; + + constructor( + schema: Schema, + tokenizer: MarkdownIt, + tokens: Record, + allowEmptyRow: boolean, + ) { this.schema = schema; this.marks = Mark.none; this.tokens = tokens; this.tokenizer = tokenizer; + this.allowEmptyRow = allowEmptyRow; } validateLink(url: string): boolean { @@ -60,6 +72,10 @@ export class MarkdownParser implements Parser { throw e; } + // for a solution with map + // if (this.allowEmptyRow) { + // mdItTokens = parseEmptyRow(mdItTokens); + // } this.parseTokens(mdItTokens); let doc; @@ -69,7 +85,7 @@ export class MarkdownParser implements Parser { doc = this.closeNode(); } while (this.stack.length); - return (doc || this.schema.topNodeType.createAndFill()) as Node; + 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.ts b/src/core/markdown/ProseMirrorTransformer.ts new file mode 100644 index 00000000..9649eb9a --- /dev/null +++ b/src/core/markdown/ProseMirrorTransformer.ts @@ -0,0 +1,44 @@ +import {Node} from 'prosemirror-model'; + +type PMNodeJSON = { + type: string; + attrs?: Record; + content?: PMNodeJSON[]; + text?: string; +}; + +type TranformFn = (node: PMNodeJSON) => void; + +class ProseMirrorTransformer { + private readonly _transformers: TranformFn[]; + + constructor(...fns: TranformFn[]) { + 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); + } + } + } +} + +const transformEmptyParagraph: TranformFn = (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; +}; + +export const pmTransformer = new ProseMirrorTransformer(transformEmptyParagraph); diff --git a/src/core/markdown/emptyRowParser/constants.ts b/src/core/markdown/emptyRowParser/constants.ts new file mode 100644 index 00000000..e3d398f3 --- /dev/null +++ b/src/core/markdown/emptyRowParser/constants.ts @@ -0,0 +1,79 @@ +import Token from 'markdown-it/lib/token'; + +export const emptyRow = (line: number): Token[] => [ + { + attrs: null, + block: true, + children: null, + content: '', + hidden: false, + info: '', + level: 0, + map: [line, line + 1], + markup: '', + meta: null, + nesting: 1, + tag: 'p', + type: 'paragraph_open', + attrIndex: function (): number { + throw new Error('Function not implemented.'); + }, + attrPush: function (): void { + throw new Error('Function not implemented.'); + }, + attrSet: function (): void { + throw new Error('Function not implemented.'); + }, + attrGet: function (): string | null { + throw new Error('Function not implemented.'); + }, + attrJoin: function (): void { + throw new Error('Function not implemented.'); + }, + }, + { + attrs: null, + block: true, + children: null, + content: '', + hidden: false, + info: '', + level: 0, + map: null, + markup: '', + meta: null, + nesting: -1, + tag: 'p', + type: 'paragraph_close', + attrIndex: function (): number { + throw new Error('Function not implemented.'); + }, + attrPush: function (): void { + throw new Error('Function not implemented.'); + }, + attrSet: function (): void { + throw new Error('Function not implemented.'); + }, + attrGet: function (): string | null { + throw new Error('Function not implemented.'); + }, + attrJoin: function (): void { + throw new Error('Function not implemented.'); + }, + }, +]; + +export const blackList: string[] = ['yfm_tbody', 'tab', 'tab-list', 'yfm_cut_content', 'yfm_block']; + +export const previosBlockRatio: {[key: string]: number} = { + yfm_table: -1, + yfm_cut_content: -1, + tabs: 1, + yfm_td: 1, + mermaid: -1, +}; + +export const blockRatio: Record = { + tabs: -1, + table: -1, +}; diff --git a/src/core/markdown/emptyRowParser/emptyRowParser.ts b/src/core/markdown/emptyRowParser/emptyRowParser.ts new file mode 100644 index 00000000..256800ed --- /dev/null +++ b/src/core/markdown/emptyRowParser/emptyRowParser.ts @@ -0,0 +1,74 @@ +import Token from 'markdown-it/lib/token'; + +import {emptyRow} from './constants'; +import {EmptyTokenParserType, checkRow, normilize} from './utils'; + +export const parseEmptyRow = (tokens: Token[]) => { + const parsedTokens: Token[] = []; + + const openBlocks: EmptyTokenParserType[] = []; + const closedBlocks: EmptyTokenParserType[] = []; + + const previosTokenData = { + index: 0, + blockName: '', + isTab: 0, + }; + + for (let i = 0; i < tokens.length; i++) { + const isOpenBlock = tokens[i].type.indexOf('_open') !== -1; + const isClosedBlock = tokens[i].type.indexOf('_close') !== -1; + + if (isOpenBlock || ['hr', 'fence', 'mermaid'].includes(tokens[i].type)) { + const activeBlock = normilize(tokens[i]); + + if (activeBlock && activeBlock.tag === 'tabs') { + activeBlock.start = tokens[i + 1]?.map?.[0] || -1; + activeBlock.end = tokens[i + 1]?.map?.[1] || -1; + + previosTokenData.isTab++; + } + + openBlocks.push(activeBlock); + let row = checkRow(activeBlock, previosTokenData); + + while (row > 0) { + parsedTokens.push(...emptyRow(i)); + row--; + } + } else if (isClosedBlock) { + let lastItem = openBlocks.pop(); + if (['yfm_cut_close'].includes(tokens[i].type)) { + lastItem = normilize(tokens[i]); + } + if (lastItem) { + closedBlocks.push(lastItem); + + if (lastItem.tag === 'tabs') { + previosTokenData.isTab--; + } + } + } + + const maps = tokens[i].map; + + if (maps) { + if (tokens[i].block && isOpenBlock) { + previosTokenData.index = maps[0]; + } else { + previosTokenData.index = maps[1]; + } + } + + previosTokenData.blockName = normilize(tokens[i]).tag; + + if (isClosedBlock) { + previosTokenData.index = (closedBlocks.at(-1) as EmptyTokenParserType).end; + previosTokenData.blockName = (closedBlocks.at(-1) as EmptyTokenParserType).tag; + } + + parsedTokens.push(tokens[i]); + } + + return parsedTokens; +}; diff --git a/src/core/markdown/emptyRowParser/utils.ts b/src/core/markdown/emptyRowParser/utils.ts new file mode 100644 index 00000000..78fcbd2d --- /dev/null +++ b/src/core/markdown/emptyRowParser/utils.ts @@ -0,0 +1,79 @@ +import Token from 'markdown-it/lib/token'; + +import {blackList, blockRatio, previosBlockRatio} from './constants'; + +export type EmptyTokenParserType = { + tag: string; + start: number; + end: number; +}; + +export type PreviosBlockInfo = { + index: number; + blockName: string; + isTab: number; +}; + +export const getRowIndex = (row: EmptyTokenParserType, _previosRow: PreviosBlockInfo) => { + const ratio = blockRatio[row.tag] || 0; + + return row.start + ratio; +}; + +export const getPreviosRowIndex = (_row: EmptyTokenParserType, previosRow: PreviosBlockInfo) => { + const ratio = previosBlockRatio[previosRow.blockName] || 0; + + return previosRow.index + ratio; +}; + +export const cornerRules: { + [key: string]: (row: EmptyTokenParserType, previosRow: PreviosBlockInfo) => number; +} = { + tabs: (row: EmptyTokenParserType, previosRow: PreviosBlockInfo) => { + let rowIndex = getRowIndex(row, previosRow); + const previosRowIndex = getPreviosRowIndex(row, previosRow); + + rowIndex -= previosRow.isTab > 1 ? 2 : 1; + + if (previosRow.isTab > 1) { + const rows = (rowIndex - previosRowIndex - 1) / 2; + + return rows; + } + + return rowIndex - previosRowIndex - 1; + }, +}; + +export const normilize = (token: Token) => { + return { + tag: token.type.replaceAll('_open', '').replaceAll('_close', ''), + start: token?.map?.[0] || -1, + end: token?.map?.[1] || -1, + }; +}; + +export const checkRow = (row: EmptyTokenParserType, previosTokenData: PreviosBlockInfo) => { + if (!blackList.includes(row.tag) && row) { + if (cornerRules[row.tag]) { + return cornerRules[row.tag](row, previosTokenData); + } + + const rowIndex = getRowIndex(row, previosTokenData); + const previosRowIndex = getPreviosRowIndex(row, previosTokenData); + + if (rowIndex === -1 || previosRowIndex === -1) return 0; + + if (rowIndex >= previosRowIndex + 2) { + if (previosTokenData.isTab) { + const rows = (rowIndex - previosRowIndex - 1) / 2; + + return rows; + } + + return rowIndex - previosRowIndex - 1; + } + } + + return 0; +}; diff --git a/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts b/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts index 47095856..7269e441 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']; + allowEmptyRows?: boolean; }; export const BaseSchemaSpecs: ExtensionAuto = (builder, opts) => { @@ -63,8 +64,12 @@ export const BaseSchemaSpecs: ExtensionAuto = (builder, }, fromMd: {tokenSpec: {name: BaseNode.Paragraph, type: 'block'}}, toMd: (state, node) => { - state.renderInline(node); - state.closeBlock(node); + if (opts.allowEmptyRows && !node.content.size) { + state.write(' \n\n'); + } else { + state.renderInline(node); + state.closeBlock(node); + } }, })); }; From 5a6b8e7ead876ac1122840405efa65dcdbb8f35e Mon Sep 17 00:00:00 2001 From: Mikhail Pavlovich Date: Mon, 2 Dec 2024 16:51:55 +0300 Subject: [PATCH 02/15] fix --- src/bundle/Editor.ts | 5 ++++- src/bundle/config/action-names.ts | 1 + src/i18n/empty-row/en.json | 3 +++ src/i18n/empty-row/index.ts | 8 ++++++++ src/i18n/empty-row/ru.json | 3 +++ src/markup/codemirror/create.ts | 2 ++ src/markup/codemirror/yfm.ts | 28 ++++++++++++++++++++++++---- src/markup/commands/emptyRow.ts | 22 ++++++++++++++++++++++ src/markup/commands/index.ts | 1 + src/shortcuts/const.ts | 2 ++ src/shortcuts/default.ts | 2 ++ 11 files changed, 72 insertions(+), 5 deletions(-) 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/commands/emptyRow.ts diff --git a/src/bundle/Editor.ts b/src/bundle/Editor.ts index a901031a..64d2e66f 100644 --- a/src/bundle/Editor.ts +++ b/src/bundle/Editor.ts @@ -280,7 +280,10 @@ export class EditorImpl extends SafeEventEmitter implements EditorI extensions: this.#markupConfig.extensions, disabledExtensions: this.#markupConfig.disabledExtensions, keymaps: this.#markupConfig.keymaps, - yfmLangOptions: {languageData: this.#markupConfig.languageData}, + yfmLangOptions: { + languageData: this.#markupConfig.languageData, + allowEmptyRows: this.#mdOptions.allowEmptyRows, + }, autocompletion: this.#markupConfig.autocompletion, directiveSyntax: this.directiveSyntax, receiver: this, diff --git a/src/bundle/config/action-names.ts b/src/bundle/config/action-names.ts index 29e127ae..72e9bdf3 100644 --- a/src/bundle/config/action-names.ts +++ b/src/bundle/config/action-names.ts @@ -14,6 +14,7 @@ const names = [ 'heading4', 'heading5', 'heading6', + 'emptyRow', 'bulletList', 'orderedList', 'liftListItem', 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/create.ts b/src/markup/codemirror/create.ts index 8396cf9b..75ffb18a 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, @@ -122,6 +123,7 @@ export function createCodemirror(params: CreateCodemirrorParams) { {key: f.toCM(A.CodeBlock)!, run: withLogger(ActionName.code_block, wrapToCodeBlock)}, {key: f.toCM(A.Cut)!, run: withLogger(ActionName.yfm_cut, wrapToYfmCut)}, {key: f.toCM(A.Note)!, run: withLogger(ActionName.yfm_note, wrapToYfmNote)}, + {key: f.toCM(A.EmptyRow)!, run: withLogger(ActionName.emptyRow, insertEmptyRow)}, { key: f.toCM(A.Cancel)!, preventDefault: true, diff --git a/src/markup/codemirror/yfm.ts b/src/markup/codemirror/yfm.ts index d6259cbf..e598e5fa 100644 --- a/src/markup/codemirror/yfm.ts +++ b/src/markup/codemirror/yfm.ts @@ -4,6 +4,7 @@ import type {Extension} from '@codemirror/state'; import {Tag, tags} from '@lezer/highlight'; import type {DelimiterType, MarkdownConfig} from '@lezer/markdown'; +import {i18n} from '../../../src/i18n/empty-row'; import {capitalize} from '../../lodash'; import {DirectiveSyntaxFacet} from './directive-facet'; @@ -81,6 +82,9 @@ export const yfmCutSnippet = snippet(yfmCutSnippetTemplate); export const yfmCutDirectiveSnippetTemplate = ':::cut [#{title}]\n#{}\n:::\n\n'; export const yfmCutDirectiveSnippet = snippet(yfmCutDirectiveSnippetTemplate); +export const emptyRowSnippetTemplate = ' \n\n'; +export const emptyRowSnippet = snippet(emptyRowSnippetTemplate); + export interface LanguageData { autocomplete: CompletionSource; [key: string]: any; @@ -88,9 +92,10 @@ export interface LanguageData { export interface YfmLangOptions { languageData?: LanguageData[]; + allowEmptyRows?: boolean; } -const mdAutocomplete: LanguageData = { +const mdAutocomplete: (allowEmptyRows?: boolean) => LanguageData = (allowEmptyRows) => ({ autocomplete: (context) => { const directiveContext = context.state.facet(DirectiveSyntaxFacet); @@ -120,6 +125,21 @@ const mdAutocomplete: LanguageData = { // } const word = context.matchBefore(/^.*/); + + if (allowEmptyRows && word?.text.startsWith('&')) { + return { + from: word.from, + options: [ + { + label: ' ', + displayLabel: i18n('snippet.text'), + type: 'text', + apply: emptyRowSnippet, + }, + ], + }; + } + if (directiveContext.option !== 'only' && word?.text.startsWith('{%')) { return { from: word.from, @@ -161,9 +181,9 @@ const mdAutocomplete: LanguageData = { } return null; }, -}; +}); -export function yfmLang({languageData = []}: YfmLangOptions = {}): Extension { +export function yfmLang({languageData = [], allowEmptyRows}: YfmLangOptions = {}): Extension { const mdSupport = markdown({ // defaultCodeLanguage: markdownLanguage, base: markdownLanguage, @@ -174,7 +194,7 @@ export function yfmLang({languageData = []}: YfmLangOptions = {}): Extension { return [ mdSupport, - mdSupport.language.data.of(mdAutocomplete), + mdSupport.language.data.of(mdAutocomplete(allowEmptyRows)), languageData.map((item) => mdSupport.language.data.of(item)), ]; } diff --git a/src/markup/commands/emptyRow.ts b/src/markup/commands/emptyRow.ts new file mode 100644 index 00000000..3e8267eb --- /dev/null +++ b/src/markup/commands/emptyRow.ts @@ -0,0 +1,22 @@ +import {EditorSelection, StateCommand} from '@codemirror/state'; + +const str = ' \n\n'; + +export const insertEmptyRow: StateCommand = ({state, dispatch}) => { + const trSpec = state.changeByRange((range) => { + const lineFrom = state.doc.lineAt(range.from); + + return { + changes: [{from: lineFrom.from, insert: str}], + range: EditorSelection.range( + range.anchor + str.length, + range.head + str.length, + range.goalColumn, + range.bidiLevel ?? undefined, + ), + }; + }); + + dispatch(state.update(trSpec)); + return true; +}; 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..38d975d1 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.Option, K.Enter]}) + .set(A.BulletList, [MK.Mod, MK.Shift, 'l']) .set(A.OrderedList, [MK.Mod, MK.Shift, 'm']) From 2d2a6ad20c594110707995a02b2036c47c8df8d9 Mon Sep 17 00:00:00 2001 From: Mikhail Pavlovich Date: Tue, 3 Dec 2024 11:48:43 +0300 Subject: [PATCH 03/15] fix PR --- src/markup/commands/emptyRow.ts | 22 +++++----------------- src/markup/commands/helpers.ts | 20 ++++++++++++++++++-- src/shortcuts/default.ts | 2 +- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/markup/commands/emptyRow.ts b/src/markup/commands/emptyRow.ts index 3e8267eb..267851a9 100644 --- a/src/markup/commands/emptyRow.ts +++ b/src/markup/commands/emptyRow.ts @@ -1,22 +1,10 @@ -import {EditorSelection, StateCommand} from '@codemirror/state'; +import {StateCommand} from '@codemirror/state'; -const str = ' \n\n'; +import {replaceOrInsertAfter} from './helpers'; export const insertEmptyRow: StateCommand = ({state, dispatch}) => { - const trSpec = state.changeByRange((range) => { - const lineFrom = state.doc.lineAt(range.from); - - return { - changes: [{from: lineFrom.from, insert: str}], - range: EditorSelection.range( - range.anchor + str.length, - range.head + str.length, - range.goalColumn, - range.bidiLevel ?? undefined, - ), - }; - }); - - dispatch(state.update(trSpec)); + const emptyRowMarkup = ' '; + const tr = replaceOrInsertAfter(state, emptyRowMarkup, true); + dispatch(state.update(tr)); return true; }; diff --git a/src/markup/commands/helpers.ts b/src/markup/commands/helpers.ts index 94629278..74b9fc3f 100644 --- a/src/markup/commands/helpers.ts +++ b/src/markup/commands/helpers.ts @@ -26,7 +26,11 @@ export function getBlockExtraLineBreaks( return {before: lineBreaksBefore, after: lineBreaksAfter}; } -export function replaceOrInsertAfter(state: EditorState, markup: string): TransactionSpec { +export function replaceOrInsertAfter( + state: EditorState, + markup: string, + shouldGetBreaks?: boolean, +): TransactionSpec { const selrange = state.selection.main; if (isFullLinesSelection(state.doc, selrange)) { const extraBreaks = getBlockExtraLineBreaks(state, { @@ -39,7 +43,19 @@ export function replaceOrInsertAfter(state: EditorState, markup: string): Transa state.lineBreak.repeat(extraBreaks.after), ); } else { - const insert = state.lineBreak.repeat(2) + markup + state.lineBreak.repeat(2); + let breaksAfter = state.lineBreak.repeat(2); + + if (shouldGetBreaks) { + const extraBreaks = getBlockExtraLineBreaks(state, { + from: state.doc.lineAt(selrange.from), + to: state.doc.lineAt(selrange.to), + }); + + breaksAfter = state.lineBreak.repeat(extraBreaks.after); + } + + const insert = state.lineBreak.repeat(2) + markup + breaksAfter; + const from = state.doc.lineAt(selrange.to).to; const selAnchor = from + insert.length - 2; return {changes: {from, insert}, selection: {anchor: selAnchor}}; diff --git a/src/shortcuts/default.ts b/src/shortcuts/default.ts index 38d975d1..3c45a369 100644 --- a/src/shortcuts/default.ts +++ b/src/shortcuts/default.ts @@ -26,7 +26,7 @@ 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.Option, K.Enter]}) + .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 296e132875e3262d79769c70b90268c6b3b72341 Mon Sep 17 00:00:00 2001 From: Mikhail Pavlovich Date: Thu, 5 Dec 2024 17:20:04 +0300 Subject: [PATCH 04/15] fix --- src/bundle/Editor.ts | 6 +- src/core/markdown/MarkdownParser.ts | 7 - src/core/markdown/emptyRowParser/constants.ts | 79 ----------- .../markdown/emptyRowParser/emptyRowParser.ts | 74 ----------- src/core/markdown/emptyRowParser/utils.ts | 79 ----------- .../base/BaseSchema/BaseSchemaSpecs/index.ts | 7 +- .../codemirror/autocomplete/directive.ts | 109 +++++++++++++++ .../codemirror/autocomplete/emptyRow.ts | 28 ++++ src/markup/codemirror/autocomplete/index.ts | 18 +++ src/markup/codemirror/yfm.ts | 124 +----------------- src/markup/commands/emptyRow.ts | 47 ++++++- src/markup/commands/helpers.ts | 10 +- 12 files changed, 214 insertions(+), 374 deletions(-) delete mode 100644 src/core/markdown/emptyRowParser/constants.ts delete mode 100644 src/core/markdown/emptyRowParser/emptyRowParser.ts delete mode 100644 src/core/markdown/emptyRowParser/utils.ts create mode 100644 src/markup/codemirror/autocomplete/directive.ts create mode 100644 src/markup/codemirror/autocomplete/emptyRow.ts create mode 100644 src/markup/codemirror/autocomplete/index.ts diff --git a/src/bundle/Editor.ts b/src/bundle/Editor.ts index 64d2e66f..c63b54d2 100644 --- a/src/bundle/Editor.ts +++ b/src/bundle/Editor.ts @@ -4,6 +4,7 @@ import {EditorView as CMEditorView} from '@codemirror/view'; import {TextSelection} from 'prosemirror-state'; import {EditorView as PMEditorView} from 'prosemirror-view'; +import {getAutocompleteConfig} from '../../src/markup/codemirror/autocomplete'; import type {CommonEditor, MarkupString} from '../common'; import { type ActionStorage, @@ -281,8 +282,9 @@ export class EditorImpl extends SafeEventEmitter implements EditorI disabledExtensions: this.#markupConfig.disabledExtensions, keymaps: this.#markupConfig.keymaps, yfmLangOptions: { - languageData: this.#markupConfig.languageData, - allowEmptyRows: this.#mdOptions.allowEmptyRows, + languageData: getAutocompleteConfig({ + allowEmptyRows: this.#mdOptions.allowEmptyRows, + }), }, autocompletion: this.#markupConfig.autocompletion, directiveSyntax: this.directiveSyntax, diff --git a/src/core/markdown/MarkdownParser.ts b/src/core/markdown/MarkdownParser.ts index f5c12c8f..c61a88b1 100644 --- a/src/core/markdown/MarkdownParser.ts +++ b/src/core/markdown/MarkdownParser.ts @@ -8,9 +8,6 @@ import type {Parser, ParserToken} from '../types/parser'; import {pmTransformer} from './ProseMirrorTransformer'; -// for a solution with map -// import {parseEmptyRow} from './emptyRowParser/emptyRowParser'; - type TokenAttrs = {[name: string]: unknown}; const openSuffix = '_open'; @@ -72,10 +69,6 @@ export class MarkdownParser implements Parser { throw e; } - // for a solution with map - // if (this.allowEmptyRow) { - // mdItTokens = parseEmptyRow(mdItTokens); - // } this.parseTokens(mdItTokens); let doc; diff --git a/src/core/markdown/emptyRowParser/constants.ts b/src/core/markdown/emptyRowParser/constants.ts deleted file mode 100644 index e3d398f3..00000000 --- a/src/core/markdown/emptyRowParser/constants.ts +++ /dev/null @@ -1,79 +0,0 @@ -import Token from 'markdown-it/lib/token'; - -export const emptyRow = (line: number): Token[] => [ - { - attrs: null, - block: true, - children: null, - content: '', - hidden: false, - info: '', - level: 0, - map: [line, line + 1], - markup: '', - meta: null, - nesting: 1, - tag: 'p', - type: 'paragraph_open', - attrIndex: function (): number { - throw new Error('Function not implemented.'); - }, - attrPush: function (): void { - throw new Error('Function not implemented.'); - }, - attrSet: function (): void { - throw new Error('Function not implemented.'); - }, - attrGet: function (): string | null { - throw new Error('Function not implemented.'); - }, - attrJoin: function (): void { - throw new Error('Function not implemented.'); - }, - }, - { - attrs: null, - block: true, - children: null, - content: '', - hidden: false, - info: '', - level: 0, - map: null, - markup: '', - meta: null, - nesting: -1, - tag: 'p', - type: 'paragraph_close', - attrIndex: function (): number { - throw new Error('Function not implemented.'); - }, - attrPush: function (): void { - throw new Error('Function not implemented.'); - }, - attrSet: function (): void { - throw new Error('Function not implemented.'); - }, - attrGet: function (): string | null { - throw new Error('Function not implemented.'); - }, - attrJoin: function (): void { - throw new Error('Function not implemented.'); - }, - }, -]; - -export const blackList: string[] = ['yfm_tbody', 'tab', 'tab-list', 'yfm_cut_content', 'yfm_block']; - -export const previosBlockRatio: {[key: string]: number} = { - yfm_table: -1, - yfm_cut_content: -1, - tabs: 1, - yfm_td: 1, - mermaid: -1, -}; - -export const blockRatio: Record = { - tabs: -1, - table: -1, -}; diff --git a/src/core/markdown/emptyRowParser/emptyRowParser.ts b/src/core/markdown/emptyRowParser/emptyRowParser.ts deleted file mode 100644 index 256800ed..00000000 --- a/src/core/markdown/emptyRowParser/emptyRowParser.ts +++ /dev/null @@ -1,74 +0,0 @@ -import Token from 'markdown-it/lib/token'; - -import {emptyRow} from './constants'; -import {EmptyTokenParserType, checkRow, normilize} from './utils'; - -export const parseEmptyRow = (tokens: Token[]) => { - const parsedTokens: Token[] = []; - - const openBlocks: EmptyTokenParserType[] = []; - const closedBlocks: EmptyTokenParserType[] = []; - - const previosTokenData = { - index: 0, - blockName: '', - isTab: 0, - }; - - for (let i = 0; i < tokens.length; i++) { - const isOpenBlock = tokens[i].type.indexOf('_open') !== -1; - const isClosedBlock = tokens[i].type.indexOf('_close') !== -1; - - if (isOpenBlock || ['hr', 'fence', 'mermaid'].includes(tokens[i].type)) { - const activeBlock = normilize(tokens[i]); - - if (activeBlock && activeBlock.tag === 'tabs') { - activeBlock.start = tokens[i + 1]?.map?.[0] || -1; - activeBlock.end = tokens[i + 1]?.map?.[1] || -1; - - previosTokenData.isTab++; - } - - openBlocks.push(activeBlock); - let row = checkRow(activeBlock, previosTokenData); - - while (row > 0) { - parsedTokens.push(...emptyRow(i)); - row--; - } - } else if (isClosedBlock) { - let lastItem = openBlocks.pop(); - if (['yfm_cut_close'].includes(tokens[i].type)) { - lastItem = normilize(tokens[i]); - } - if (lastItem) { - closedBlocks.push(lastItem); - - if (lastItem.tag === 'tabs') { - previosTokenData.isTab--; - } - } - } - - const maps = tokens[i].map; - - if (maps) { - if (tokens[i].block && isOpenBlock) { - previosTokenData.index = maps[0]; - } else { - previosTokenData.index = maps[1]; - } - } - - previosTokenData.blockName = normilize(tokens[i]).tag; - - if (isClosedBlock) { - previosTokenData.index = (closedBlocks.at(-1) as EmptyTokenParserType).end; - previosTokenData.blockName = (closedBlocks.at(-1) as EmptyTokenParserType).tag; - } - - parsedTokens.push(tokens[i]); - } - - return parsedTokens; -}; diff --git a/src/core/markdown/emptyRowParser/utils.ts b/src/core/markdown/emptyRowParser/utils.ts deleted file mode 100644 index 78fcbd2d..00000000 --- a/src/core/markdown/emptyRowParser/utils.ts +++ /dev/null @@ -1,79 +0,0 @@ -import Token from 'markdown-it/lib/token'; - -import {blackList, blockRatio, previosBlockRatio} from './constants'; - -export type EmptyTokenParserType = { - tag: string; - start: number; - end: number; -}; - -export type PreviosBlockInfo = { - index: number; - blockName: string; - isTab: number; -}; - -export const getRowIndex = (row: EmptyTokenParserType, _previosRow: PreviosBlockInfo) => { - const ratio = blockRatio[row.tag] || 0; - - return row.start + ratio; -}; - -export const getPreviosRowIndex = (_row: EmptyTokenParserType, previosRow: PreviosBlockInfo) => { - const ratio = previosBlockRatio[previosRow.blockName] || 0; - - return previosRow.index + ratio; -}; - -export const cornerRules: { - [key: string]: (row: EmptyTokenParserType, previosRow: PreviosBlockInfo) => number; -} = { - tabs: (row: EmptyTokenParserType, previosRow: PreviosBlockInfo) => { - let rowIndex = getRowIndex(row, previosRow); - const previosRowIndex = getPreviosRowIndex(row, previosRow); - - rowIndex -= previosRow.isTab > 1 ? 2 : 1; - - if (previosRow.isTab > 1) { - const rows = (rowIndex - previosRowIndex - 1) / 2; - - return rows; - } - - return rowIndex - previosRowIndex - 1; - }, -}; - -export const normilize = (token: Token) => { - return { - tag: token.type.replaceAll('_open', '').replaceAll('_close', ''), - start: token?.map?.[0] || -1, - end: token?.map?.[1] || -1, - }; -}; - -export const checkRow = (row: EmptyTokenParserType, previosTokenData: PreviosBlockInfo) => { - if (!blackList.includes(row.tag) && row) { - if (cornerRules[row.tag]) { - return cornerRules[row.tag](row, previosTokenData); - } - - const rowIndex = getRowIndex(row, previosTokenData); - const previosRowIndex = getPreviosRowIndex(row, previosTokenData); - - if (rowIndex === -1 || previosRowIndex === -1) return 0; - - if (rowIndex >= previosRowIndex + 2) { - if (previosTokenData.isTab) { - const rows = (rowIndex - previosRowIndex - 1) / 2; - - return rows; - } - - return rowIndex - previosRowIndex - 1; - } - } - - return 0; -}; diff --git a/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts b/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts index 7269e441..81078922 100644 --- a/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts +++ b/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts @@ -63,9 +63,12 @@ export const BaseSchemaSpecs: ExtensionAuto = (builder, : undefined, }, fromMd: {tokenSpec: {name: BaseNode.Paragraph, type: 'block'}}, - toMd: (state, node) => { + toMd: (state, node, parent) => { if (opts.allowEmptyRows && !node.content.size) { - state.write(' \n\n'); + const isParentEmpty = parent.content.size % parent.content.childCount === 0; + if (!isParentEmpty) { + state.write(' \n\n'); + } } else { state.renderInline(node); state.closeBlock(node); diff --git a/src/markup/codemirror/autocomplete/directive.ts b/src/markup/codemirror/autocomplete/directive.ts new file mode 100644 index 00000000..803cbd3b --- /dev/null +++ b/src/markup/codemirror/autocomplete/directive.ts @@ -0,0 +1,109 @@ +import {Completion, CompletionContext, snippet} from '@codemirror/autocomplete'; + +import {capitalize} from '../../../lodash'; +import {DirectiveSyntaxFacet} from '../directive-facet'; + +export type YfmNoteType = 'info' | 'tip' | 'warning' | 'alert'; +export const yfmNoteTypes: readonly YfmNoteType[] = ['info', 'tip', 'warning', 'alert']; +export const yfmNoteSnippetTemplate = (type: YfmNoteType) => + `{% note ${type} %}\n\n#{}\n\n{% endnote %}\n\n` as const; +export const yfmNoteSnippets: Record> = { + info: snippet(yfmNoteSnippetTemplate('info')), + tip: snippet(yfmNoteSnippetTemplate('tip')), + warning: snippet(yfmNoteSnippetTemplate('warning')), + alert: snippet(yfmNoteSnippetTemplate('alert')), +}; + +export const yfmCutSnippetTemplate = '{% cut "#{title}" %}\n\n#{}\n\n{% endcut %}\n\n'; +export const yfmCutSnippet = snippet(yfmCutSnippetTemplate); + +export const yfmCutDirectiveSnippetTemplate = ':::cut [#{title}]\n#{}\n:::\n\n'; +export const yfmCutDirectiveSnippet = snippet(yfmCutDirectiveSnippetTemplate); + +export const directiveAutocomplete = { + autocomplete: (context: CompletionContext) => { + const directiveContext = context.state.facet(DirectiveSyntaxFacet); + + // TODO: add more actions and re-enable + // let word = context.matchBefore(/\/.*/); + // if (word) { + // return { + // from: word.from, + // options: [ + // ...yfmNoteTypes.map((type, index) => ({ + // label: `/yfm note ${type}`, + // displayLabel: `YFM Note ${capitalize(type)}`, + // type: 'text', + // apply: yfmNoteSnippets[type], + // boost: -index, + // })), + // { + // label: '/yfm cut', + // displayLabel: 'YFM Cut', + // type: 'text', + // apply: directiveFacet.shouldInsertDirectiveMarkup('yfmCut') + // ? yfmCutDirectiveSnippet + // : yfmCutSnippet, + // }, + // ], + // }; + // } + + const word = context.matchBefore(/^.*/); + + // if (allowEmptyRows && word?.text.startsWith('&')) { + // return { + // from: word.from, + // options: [ + // { + // label: ' ', + // displayLabel: i18n('snippet.text'), + // type: 'text', + // apply: emptyRowSnippet, + // }, + // ], + // }; + // } + + if (directiveContext.option !== 'only' && word?.text.startsWith('{%')) { + return { + from: word.from, + options: [ + ...yfmNoteTypes.map((type, index) => ({ + label: `{% note ${type}`, + displayLabel: capitalize(type), + type: 'text', + section: 'YFM Note', + apply: yfmNoteSnippets[type], + boost: -index, + })), + { + label: '{% cut', + displayLabel: 'YFM Cut', + type: 'text', + apply: directiveContext.shouldInsertDirectiveMarkup('yfmCut') + ? yfmCutDirectiveSnippet + : yfmCutSnippet, + }, + ], + }; + } + if (directiveContext.option !== 'disabled' && word?.text.startsWith(':')) { + const options: Completion[] = []; + + if (directiveContext.valueFor('yfmCut') !== 'disabled') { + options.push({ + label: ':::cut', + displayLabel: 'YFM Cut', + type: 'text', + apply: yfmCutDirectiveSnippet, + }); + } + + if (options.length) { + return {from: word.from, options}; + } + } + return null; + }, +}; 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..0c40f5f9 --- /dev/null +++ b/src/markup/codemirror/autocomplete/index.ts @@ -0,0 +1,18 @@ +import {directiveAutocomplete} from './directive'; +import {emptyRowAutocomplete} from './emptyRow'; + +type GetAutocompleteConfig = { + allowEmptyRows?: boolean; +}; + +export const getAutocompleteConfig = ({allowEmptyRows}: GetAutocompleteConfig) => { + const autocompleteItems = []; + + if (allowEmptyRows) { + autocompleteItems.push(emptyRowAutocomplete); + } + + autocompleteItems.push(directiveAutocomplete); + + return autocompleteItems; +}; diff --git a/src/markup/codemirror/yfm.ts b/src/markup/codemirror/yfm.ts index e598e5fa..2b7f6342 100644 --- a/src/markup/codemirror/yfm.ts +++ b/src/markup/codemirror/yfm.ts @@ -1,14 +1,9 @@ -import {Completion, CompletionSource, snippet} from '@codemirror/autocomplete'; +import {CompletionSource} from '@codemirror/autocomplete'; import {markdown, markdownLanguage} from '@codemirror/lang-markdown'; import type {Extension} from '@codemirror/state'; import {Tag, tags} from '@lezer/highlight'; import type {DelimiterType, MarkdownConfig} from '@lezer/markdown'; -import {i18n} from '../../../src/i18n/empty-row'; -import {capitalize} from '../../lodash'; - -import {DirectiveSyntaxFacet} from './directive-facet'; - export const customTags = { underline: Tag.define(), monospace: Tag.define(), @@ -65,26 +60,6 @@ const MarkedExtension = mdInlineFactory({ tag: customTags.marked, }); -export type YfmNoteType = 'info' | 'tip' | 'warning' | 'alert'; -export const yfmNoteTypes: readonly YfmNoteType[] = ['info', 'tip', 'warning', 'alert']; -export const yfmNoteSnippetTemplate = (type: YfmNoteType) => - `{% note ${type} %}\n\n#{}\n\n{% endnote %}\n\n` as const; -export const yfmNoteSnippets: Record> = { - info: snippet(yfmNoteSnippetTemplate('info')), - tip: snippet(yfmNoteSnippetTemplate('tip')), - warning: snippet(yfmNoteSnippetTemplate('warning')), - alert: snippet(yfmNoteSnippetTemplate('alert')), -}; - -export const yfmCutSnippetTemplate = '{% cut "#{title}" %}\n\n#{}\n\n{% endcut %}\n\n'; -export const yfmCutSnippet = snippet(yfmCutSnippetTemplate); - -export const yfmCutDirectiveSnippetTemplate = ':::cut [#{title}]\n#{}\n:::\n\n'; -export const yfmCutDirectiveSnippet = snippet(yfmCutDirectiveSnippetTemplate); - -export const emptyRowSnippetTemplate = ' \n\n'; -export const emptyRowSnippet = snippet(emptyRowSnippetTemplate); - export interface LanguageData { autocomplete: CompletionSource; [key: string]: any; @@ -92,98 +67,9 @@ export interface LanguageData { export interface YfmLangOptions { languageData?: LanguageData[]; - allowEmptyRows?: boolean; } -const mdAutocomplete: (allowEmptyRows?: boolean) => LanguageData = (allowEmptyRows) => ({ - autocomplete: (context) => { - const directiveContext = context.state.facet(DirectiveSyntaxFacet); - - // TODO: add more actions and re-enable - // let word = context.matchBefore(/\/.*/); - // if (word) { - // return { - // from: word.from, - // options: [ - // ...yfmNoteTypes.map((type, index) => ({ - // label: `/yfm note ${type}`, - // displayLabel: `YFM Note ${capitalize(type)}`, - // type: 'text', - // apply: yfmNoteSnippets[type], - // boost: -index, - // })), - // { - // label: '/yfm cut', - // displayLabel: 'YFM Cut', - // type: 'text', - // apply: directiveFacet.shouldInsertDirectiveMarkup('yfmCut') - // ? yfmCutDirectiveSnippet - // : yfmCutSnippet, - // }, - // ], - // }; - // } - - const word = context.matchBefore(/^.*/); - - if (allowEmptyRows && word?.text.startsWith('&')) { - return { - from: word.from, - options: [ - { - label: ' ', - displayLabel: i18n('snippet.text'), - type: 'text', - apply: emptyRowSnippet, - }, - ], - }; - } - - if (directiveContext.option !== 'only' && word?.text.startsWith('{%')) { - return { - from: word.from, - options: [ - ...yfmNoteTypes.map((type, index) => ({ - label: `{% note ${type}`, - displayLabel: capitalize(type), - type: 'text', - section: 'YFM Note', - apply: yfmNoteSnippets[type], - boost: -index, - })), - { - label: '{% cut', - displayLabel: 'YFM Cut', - type: 'text', - apply: directiveContext.shouldInsertDirectiveMarkup('yfmCut') - ? yfmCutDirectiveSnippet - : yfmCutSnippet, - }, - ], - }; - } - if (directiveContext.option !== 'disabled' && word?.text.startsWith(':')) { - const options: Completion[] = []; - - if (directiveContext.valueFor('yfmCut') !== 'disabled') { - options.push({ - label: ':::cut', - displayLabel: 'YFM Cut', - type: 'text', - apply: yfmCutDirectiveSnippet, - }); - } - - if (options.length) { - return {from: word.from, options}; - } - } - return null; - }, -}); - -export function yfmLang({languageData = [], allowEmptyRows}: YfmLangOptions = {}): Extension { +export function yfmLang({languageData = []}: YfmLangOptions = {}): Extension { const mdSupport = markdown({ // defaultCodeLanguage: markdownLanguage, base: markdownLanguage, @@ -192,9 +78,5 @@ export function yfmLang({languageData = [], allowEmptyRows}: YfmLangOptions = {} extensions: [UnderlineExtension, MonospaceExtension, MarkedExtension], }); - return [ - mdSupport, - mdSupport.language.data.of(mdAutocomplete(allowEmptyRows)), - languageData.map((item) => mdSupport.language.data.of(item)), - ]; + return [mdSupport, languageData.map((item) => mdSupport.language.data.of(item))]; } diff --git a/src/markup/commands/emptyRow.ts b/src/markup/commands/emptyRow.ts index 267851a9..aa77d169 100644 --- a/src/markup/commands/emptyRow.ts +++ b/src/markup/commands/emptyRow.ts @@ -1,10 +1,47 @@ -import {StateCommand} from '@codemirror/state'; - -import {replaceOrInsertAfter} from './helpers'; +import {EditorState, Line, StateCommand} from '@codemirror/state'; export const insertEmptyRow: StateCommand = ({state, dispatch}) => { const emptyRowMarkup = ' '; - const tr = replaceOrInsertAfter(state, emptyRowMarkup, true); - dispatch(state.update(tr)); + + 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, from: Line) { + let before = 0; + let after = 0; + let selection = 2; + + if (from.text) { + before = 2; + } else if (from.number > 1 && state.doc.line(from.number - 1).text) { + before = 1; + } + + if (from.number + 1 <= state.doc.lines && state.doc.line(from.number + 1).text) { + after = 1; + selection = 1; + } + if (from.number === state.doc.lines) { + after = 2; + selection = 0; + } + + return {before, after, selection}; +} diff --git a/src/markup/commands/helpers.ts b/src/markup/commands/helpers.ts index 74b9fc3f..1ab668dd 100644 --- a/src/markup/commands/helpers.ts +++ b/src/markup/commands/helpers.ts @@ -43,7 +43,7 @@ export function replaceOrInsertAfter( state.lineBreak.repeat(extraBreaks.after), ); } else { - let breaksAfter = state.lineBreak.repeat(2); + let breaksAfter = 2; if (shouldGetBreaks) { const extraBreaks = getBlockExtraLineBreaks(state, { @@ -51,18 +51,18 @@ export function replaceOrInsertAfter( to: state.doc.lineAt(selrange.to), }); - breaksAfter = state.lineBreak.repeat(extraBreaks.after); + breaksAfter = extraBreaks.after; } - const insert = state.lineBreak.repeat(2) + markup + breaksAfter; + const insert = state.lineBreak.repeat(2) + markup + state.lineBreak.repeat(breaksAfter); const from = state.doc.lineAt(selrange.to).to; - const selAnchor = from + insert.length - 2; + const selAnchor = from + insert.length + breaksAfter; return {changes: {from, insert}, selection: {anchor: selAnchor}}; } } -function isFullLinesSelection(doc: Text, range: SelectionRange): boolean { +export function isFullLinesSelection(doc: Text, range: SelectionRange): boolean { const fromLine = doc.lineAt(range.from); const toLine = doc.lineAt(range.to); return range.from <= fromLine.from && range.to >= toLine.to; From 63bad68f18ca3b2893c3ee0df9a6916f6325f17d Mon Sep 17 00:00:00 2001 From: Mikhail Pavlovich Date: Thu, 5 Dec 2024 17:39:15 +0300 Subject: [PATCH 05/15] fix emptyRow shortcut --- src/markup/commands/emptyRow.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/markup/commands/emptyRow.ts b/src/markup/commands/emptyRow.ts index aa77d169..438f3bd5 100644 --- a/src/markup/commands/emptyRow.ts +++ b/src/markup/commands/emptyRow.ts @@ -23,22 +23,28 @@ export const insertEmptyRow: StateCommand = ({state, dispatch}) => { return true; }; -function getBlockExtraLineBreaks(state: EditorState, from: Line) { +function getBlockExtraLineBreaks(state: EditorState, line: Line) { let before = 0; let after = 0; let selection = 2; - if (from.text) { + if (line.text) { before = 2; - } else if (from.number > 1 && state.doc.line(from.number - 1).text) { + } else if (line.number > 1 && state.doc.line(line.number - 1).text) { before = 1; } - if (from.number + 1 <= state.doc.lines && state.doc.line(from.number + 1).text) { + if (line.number + 1 <= state.doc.lines && state.doc.line(line.number + 1).text) { after = 1; selection = 1; - } - if (from.number === state.doc.lines) { + } 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; } From 4ef6f8d4672ff8a427b4d9f6fa729c5f8364c425 Mon Sep 17 00:00:00 2001 From: Mikhail Pavlovich Date: Mon, 9 Dec 2024 11:55:50 +0300 Subject: [PATCH 06/15] fix PR --- src/bundle/Editor.ts | 2 +- src/bundle/types.ts | 3 +++ src/bundle/useMarkdownEditor.ts | 6 ++++++ src/core/Editor.ts | 7 ++++--- src/core/ExtensionsManager.ts | 14 ++++++++------ src/core/ParserTokensRegistry.ts | 5 +++-- src/core/markdown/Markdown.test.ts | 2 +- src/core/markdown/MarkdownParser.test.ts | 2 +- src/core/markdown/MarkdownParser.ts | 10 ++++++---- .../ProseMirrorTransformer/emptyRowParser.ts | 8 ++++++++ .../ProseMirrorTransformer/getTransformers.ts | 19 +++++++++++++++++++ .../index.ts} | 17 ++++------------- .../base/BaseSchema/BaseSchemaSpecs/index.ts | 2 +- 13 files changed, 65 insertions(+), 32 deletions(-) create mode 100644 src/core/markdown/ProseMirrorTransformer/emptyRowParser.ts create mode 100644 src/core/markdown/ProseMirrorTransformer/getTransformers.ts rename src/core/markdown/{ProseMirrorTransformer.ts => ProseMirrorTransformer/index.ts} (55%) diff --git a/src/bundle/Editor.ts b/src/bundle/Editor.ts index c63b54d2..fbe788e2 100644 --- a/src/bundle/Editor.ts +++ b/src/bundle/Editor.ts @@ -249,7 +249,7 @@ export class EditorImpl extends SafeEventEmitter implements EditorI mdPreset, initialContent: this.#markup, extensions: this.#extensions, - allowEmptyRows: this.#mdOptions.allowEmptyRows, + pmTransformers: this.#mdOptions.pmTransformers, allowHTML: this.#mdOptions.html, linkify: this.#mdOptions.linkify, linkifyTlds: this.#mdOptions.linkifyTlds, diff --git a/src/bundle/types.ts b/src/bundle/types.ts index 344a9a86..7b83983c 100644 --- a/src/bundle/types.ts +++ b/src/bundle/types.ts @@ -2,6 +2,8 @@ import type {ReactNode} from 'react'; +import {TransformFn} from 'src/core/markdown/ProseMirrorTransformer'; + import type {MarkupString} from '../common'; import type {EscapeConfig, Extension} from '../core'; import type {CreateCodemirrorParams, YfmLangOptions} from '../markup'; @@ -32,6 +34,7 @@ export type MarkdownEditorMdOptions = { breaks?: boolean; linkify?: boolean; linkifyTlds?: string | string[]; + pmTransformers?: TransformFn[]; }; export type MarkdownEditorInitialOptions = { diff --git a/src/bundle/useMarkdownEditor.ts b/src/bundle/useMarkdownEditor.ts index 0a29263f..8eb90ca9 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'; @@ -42,6 +43,10 @@ export function useMarkdownEditor( props.needToSetDimensionsForUploadedImages; const enableNewImageSizeCalculation = experimental.enableNewImageSizeCalculation; + const pmTransformers = getPMTransformers({ + emptyRowTransformer: allowEmptyRows, + }); + const directiveSyntax = new DirectiveSyntaxContext(experimental.directiveSyntax); const extensions: Extension = (builder) => { @@ -84,6 +89,7 @@ export function useMarkdownEditor( html: md.html ?? props.allowHTML, linkify: md.linkify ?? props.linkify, linkifyTlds: md.linkifyTlds ?? props.linkifyTlds, + pmTransformers: pmTransformers, }, initial: { ...initial, diff --git a/src/core/Editor.ts b/src/core/Editor.ts index 0473f5b3..92c5bfaa 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,7 +31,7 @@ export type WysiwygEditorOptions = { mdPreset?: PresetName; allowHTML?: boolean; linkify?: boolean; - allowEmptyRows?: boolean; + pmTransformers?: TransformFn[]; linkifyTlds?: string | string[]; escapeConfig?: EscapeConfig; /** Call on any state change (move cursor, change selection, etc...) */ @@ -75,7 +76,7 @@ export class WysiwygEditor implements CommonEditor, ActionStorage { allowHTML, mdPreset, linkify, - allowEmptyRows, + pmTransformers, linkifyTlds, escapeConfig, onChange, @@ -92,7 +93,7 @@ export class WysiwygEditor implements CommonEditor, ActionStorage { actions, } = ExtensionsManager.process(extensions, { // "breaks" option only affects the renderer, but not the parser - mdOpts: {allowEmptyRows, html: allowHTML, linkify, breaks: true, preset: mdPreset}, + mdOpts: {pmTransformers, html: allowHTML, linkify, breaks: true, preset: mdPreset}, linkifyTlds, }); diff --git a/src/core/ExtensionsManager.ts b/src/core/ExtensionsManager.ts index b8f700b6..5c484899 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, @@ -22,7 +23,7 @@ type ExtensionsManagerParams = { }; type ExtensionsManagerOptions = { - mdOpts?: MarkdownIt.Options & {preset?: PresetName; allowEmptyRows?: boolean}; + mdOpts?: MarkdownIt.Options & {preset?: PresetName; pmTransformers?: TransformFn[]}; linkifyTlds?: string | string[]; }; @@ -38,6 +39,8 @@ export class ExtensionsManager { #nodeViewCreators = new Map NodeViewConstructor>(); #markViewCreators = new Map MarkViewConstructor>(); + #pmTransformers: TransformFn[] = []; + #mdForMarkup: MarkdownIt; #mdForText: MarkdownIt; #extensions: Extension; @@ -49,7 +52,6 @@ export class ExtensionsManager { #actions: Record = {}; #nodeViews: Record = {}; #markViews: Record = {}; - #allowEmptyRows = false; constructor({extensions, options = {}}: ExtensionsManagerParams) { this.#extensions = extensions; @@ -63,8 +65,8 @@ export class ExtensionsManager { this.#mdForText.linkify.tlds(options.linkifyTlds, true); } - if (options.mdOpts?.allowEmptyRows) { - this.#allowEmptyRows = options.mdOpts?.allowEmptyRows; + if (options.mdOpts?.pmTransformers) { + this.#pmTransformers = options.mdOpts?.pmTransformers; } // TODO: add prefilled context @@ -126,12 +128,12 @@ export class ExtensionsManager { markupParser: this.#parserRegistry.createParser( schema, this.#mdForMarkup, - this.#allowEmptyRows, + this.#pmTransformers, ), textParser: this.#parserRegistry.createParser( schema, this.#mdForText, - this.#allowEmptyRows, + this.#pmTransformers, ), serializer: this.#serializerRegistry.createSerializer(), }; diff --git a/src/core/ParserTokensRegistry.ts b/src/core/ParserTokensRegistry.ts index c024c6f8..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, allowEmptyRows: boolean): Parser { - return new MarkdownParser(schema, tokenizer, this.#tokens, allowEmptyRows); + 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 20bd079e..82996bde 100644 --- a/src/core/markdown/Markdown.test.ts +++ b/src/core/markdown/Markdown.test.ts @@ -34,7 +34,7 @@ const parser: Parser = new MarkdownParser( strong: {type: 'mark', name: 'strong'}, code_inline: {type: 'mark', name: 'code', noCloseToken: true}, }, - false, + [], ); const serializer = new MarkdownSerializer( { diff --git a/src/core/markdown/MarkdownParser.test.ts b/src/core/markdown/MarkdownParser.test.ts index 31f34245..0085275d 100644 --- a/src/core/markdown/MarkdownParser.test.ts +++ b/src/core/markdown/MarkdownParser.test.ts @@ -15,7 +15,7 @@ const testParser: Parser = new MarkdownParser( paragraph: {type: 'block', name: 'paragraph'}, softbreak: {type: 'node', name: 'hard_break'}, }, - false, + [], ); function parseWith(parser: Parser) { diff --git a/src/core/markdown/MarkdownParser.ts b/src/core/markdown/MarkdownParser.ts index c61a88b1..d0344a71 100644 --- a/src/core/markdown/MarkdownParser.ts +++ b/src/core/markdown/MarkdownParser.ts @@ -6,7 +6,7 @@ import {Mark, MarkType, Node, NodeType, Schema} from 'prosemirror-model'; import {logger} from '../../logger'; import type {Parser, ParserToken} from '../types/parser'; -import {pmTransformer} from './ProseMirrorTransformer'; +import {ProseMirrorTransformer, TransformFn} from './ProseMirrorTransformer'; type TokenAttrs = {[name: string]: unknown}; @@ -24,19 +24,19 @@ export class MarkdownParser implements Parser { marks: readonly Mark[]; tokens: Record; tokenizer: MarkdownIt; - allowEmptyRow: boolean; + pmTransformers: TransformFn[]; constructor( schema: Schema, tokenizer: MarkdownIt, tokens: Record, - allowEmptyRow: boolean, + pmTransformers: TransformFn[], ) { this.schema = schema; this.marks = Mark.none; this.tokens = tokens; this.tokenizer = tokenizer; - this.allowEmptyRow = allowEmptyRow; + this.pmTransformers = pmTransformers; } validateLink(url: string): boolean { @@ -78,6 +78,8 @@ export class MarkdownParser implements Parser { doc = this.closeNode(); } while (this.stack.length); + 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/emptyRowParser.ts b/src/core/markdown/ProseMirrorTransformer/emptyRowParser.ts new file mode 100644 index 00000000..4f0439b9 --- /dev/null +++ b/src/core/markdown/ProseMirrorTransformer/emptyRowParser.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..39c30f91 --- /dev/null +++ b/src/core/markdown/ProseMirrorTransformer/getTransformers.ts @@ -0,0 +1,19 @@ +import {transformEmptyParagraph} from './emptyRowParser'; + +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.ts b/src/core/markdown/ProseMirrorTransformer/index.ts similarity index 55% rename from src/core/markdown/ProseMirrorTransformer.ts rename to src/core/markdown/ProseMirrorTransformer/index.ts index 9649eb9a..2b5d1ef2 100644 --- a/src/core/markdown/ProseMirrorTransformer.ts +++ b/src/core/markdown/ProseMirrorTransformer/index.ts @@ -7,12 +7,12 @@ type PMNodeJSON = { text?: string; }; -type TranformFn = (node: PMNodeJSON) => void; +export type TransformFn = (node: PMNodeJSON) => void; -class ProseMirrorTransformer { - private readonly _transformers: TranformFn[]; +export class ProseMirrorTransformer { + private readonly _transformers: TransformFn[]; - constructor(...fns: TranformFn[]) { + constructor(fns: TransformFn[]) { this._transformers = fns; } @@ -33,12 +33,3 @@ class ProseMirrorTransformer { } } } - -const transformEmptyParagraph: TranformFn = (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; -}; - -export const pmTransformer = new ProseMirrorTransformer(transformEmptyParagraph); diff --git a/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts b/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts index 81078922..5798476b 100644 --- a/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts +++ b/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts @@ -65,7 +65,7 @@ export const BaseSchemaSpecs: ExtensionAuto = (builder, fromMd: {tokenSpec: {name: BaseNode.Paragraph, type: 'block'}}, toMd: (state, node, parent) => { if (opts.allowEmptyRows && !node.content.size) { - const isParentEmpty = parent.content.size % parent.content.childCount === 0; + const isParentEmpty = parent.content.size / parent.content.childCount === 2; if (!isParentEmpty) { state.write(' \n\n'); } From 533d944775f1254a53ccbb4645eefb84f7f09620 Mon Sep 17 00:00:00 2001 From: Mikhail Pavlovich Date: Mon, 16 Dec 2024 17:42:44 +0300 Subject: [PATCH 07/15] fix PR --- demo/components/Playground.tsx | 6 +- src/bundle/Editor.ts | 2 +- src/bundle/types.ts | 2 +- src/bundle/useMarkdownEditor.ts | 8 +- src/bundle/wysiwyg-preset.ts | 4 +- .../ProseMirrorTransformer/getTransformers.ts | 1 + .../base/BaseSchema/BaseSchemaSpecs/index.ts | 17 ++- .../codemirror/autocomplete/directive.ts | 109 ------------------ src/markup/codemirror/autocomplete/index.ts | 11 +- src/markup/codemirror/yfm.ts | 102 +++++++++++++++- src/markup/commands/helpers.ts | 24 +--- 11 files changed, 136 insertions(+), 150 deletions(-) delete mode 100644 src/markup/codemirror/autocomplete/directive.ts diff --git a/demo/components/Playground.tsx b/demo/components/Playground.tsx index 0a84a6ef..56716cb8 100644 --- a/demo/components/Playground.tsx +++ b/demo/components/Playground.tsx @@ -76,7 +76,7 @@ export type PlaygroundProps = { allowHTML?: boolean; settingsVisible?: boolean; initialEditor?: MarkdownEditorMode; - allowEmptyRows?: boolean; + preserveEmptyRows?: boolean; breaks?: boolean; linkify?: boolean; linkifyTlds?: string | string[]; @@ -127,7 +127,7 @@ export const Playground = React.memo((props) => { allowHTML, breaks, linkify, - allowEmptyRows, + preserveEmptyRows, linkifyTlds, sanitizeHtml, prepareRawMarkup, @@ -192,7 +192,7 @@ export const Playground = React.memo((props) => { directiveSyntax, }, md: { - allowEmptyRows: allowEmptyRows, + preserveEmptyRows: preserveEmptyRows, }, prepareRawMarkup: prepareRawMarkup ? (value) => '**prepare raw markup**\n\n' + value diff --git a/src/bundle/Editor.ts b/src/bundle/Editor.ts index fbe788e2..1471b47c 100644 --- a/src/bundle/Editor.ts +++ b/src/bundle/Editor.ts @@ -283,7 +283,7 @@ export class EditorImpl extends SafeEventEmitter implements EditorI keymaps: this.#markupConfig.keymaps, yfmLangOptions: { languageData: getAutocompleteConfig({ - allowEmptyRows: this.#mdOptions.allowEmptyRows, + preserveEmptyRows: this.#mdOptions.preserveEmptyRows, }), }, autocompletion: this.#markupConfig.autocompletion, diff --git a/src/bundle/types.ts b/src/bundle/types.ts index 7b83983c..17e005db 100644 --- a/src/bundle/types.ts +++ b/src/bundle/types.ts @@ -30,7 +30,7 @@ export type ParseInsertedUrlAsImage = (text: string) => {imageUrl: string; title export type MarkdownEditorMdOptions = { html?: boolean; - allowEmptyRows?: boolean; + preserveEmptyRows?: boolean; breaks?: boolean; linkify?: boolean; linkifyTlds?: string | string[]; diff --git a/src/bundle/useMarkdownEditor.ts b/src/bundle/useMarkdownEditor.ts index 8eb90ca9..243004e8 100644 --- a/src/bundle/useMarkdownEditor.ts +++ b/src/bundle/useMarkdownEditor.ts @@ -34,7 +34,7 @@ export function useMarkdownEditor( } = props; const breaks = md.breaks ?? props.breaks; - const allowEmptyRows = md.allowEmptyRows; + const preserveEmptyRows = md.preserveEmptyRows; const preset: MarkdownEditorPreset = props.preset ?? 'full'; const renderStorage = new ReactRenderStorage(); const uploadFile = handlers.uploadFile ?? props.fileUploadHandler; @@ -44,7 +44,7 @@ export function useMarkdownEditor( const enableNewImageSizeCalculation = experimental.enableNewImageSizeCalculation; const pmTransformers = getPMTransformers({ - emptyRowTransformer: allowEmptyRows, + emptyRowTransformer: preserveEmptyRows, }); const directiveSyntax = new DirectiveSyntaxContext(experimental.directiveSyntax); @@ -65,7 +65,7 @@ export function useMarkdownEditor( editor.emit('submit', null); return true; }, - allowEmptyRows: allowEmptyRows, + preserveEmptyRows: preserveEmptyRows, mdBreaks: breaks, fileUploadHandler: uploadFile, needToSetDimensionsForUploadedImages, @@ -89,7 +89,7 @@ export function useMarkdownEditor( html: md.html ?? props.allowHTML, linkify: md.linkify ?? props.linkify, linkifyTlds: md.linkifyTlds ?? props.linkifyTlds, - pmTransformers: pmTransformers, + pmTransformers, }, initial: { ...initial, diff --git a/src/bundle/wysiwyg-preset.ts b/src/bundle/wysiwyg-preset.ts index 19e8f816..4be28611 100644 --- a/src/bundle/wysiwyg-preset.ts +++ b/src/bundle/wysiwyg-preset.ts @@ -26,7 +26,7 @@ export type BundlePresetOptions = ExtensionsOptions & EditorModeKeymapOptions & { preset: MarkdownEditorPreset; mdBreaks?: boolean; - allowEmptyRows?: boolean; + preserveEmptyRows?: boolean; fileUploadHandler?: FileUploadHandler; /** * If we need to set dimensions for uploaded images @@ -68,7 +68,7 @@ export const BundlePreset: ExtensionAuto = (builder, opts) !node.text && parent?.type.name === BaseNode.Doc && parent.childCount === 1; return isDocEmpty ? i18nPlaceholder('doc_empty') : null; }, - allowEmptyRows: opts.allowEmptyRows, + preserveEmptyRows: opts.preserveEmptyRows, ...opts.baseSchema, }, }; diff --git a/src/core/markdown/ProseMirrorTransformer/getTransformers.ts b/src/core/markdown/ProseMirrorTransformer/getTransformers.ts index 39c30f91..d9e05884 100644 --- a/src/core/markdown/ProseMirrorTransformer/getTransformers.ts +++ b/src/core/markdown/ProseMirrorTransformer/getTransformers.ts @@ -1,3 +1,4 @@ +// TODO: add a new method to the ExtensionBuilder import {transformEmptyParagraph} from './emptyRowParser'; import {TransformFn} from '.'; diff --git a/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts b/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts index 5798476b..3b332bbd 100644 --- a/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts +++ b/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts @@ -16,7 +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']; - allowEmptyRows?: boolean; + preserveEmptyRows?: boolean; }; export const BaseSchemaSpecs: ExtensionAuto = (builder, opts) => { @@ -64,8 +64,19 @@ export const BaseSchemaSpecs: ExtensionAuto = (builder, }, fromMd: {tokenSpec: {name: BaseNode.Paragraph, type: 'block'}}, toMd: (state, node, parent) => { - if (opts.allowEmptyRows && !node.content.size) { - const isParentEmpty = parent.content.size / parent.content.childCount === 2; + 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'); } diff --git a/src/markup/codemirror/autocomplete/directive.ts b/src/markup/codemirror/autocomplete/directive.ts deleted file mode 100644 index 803cbd3b..00000000 --- a/src/markup/codemirror/autocomplete/directive.ts +++ /dev/null @@ -1,109 +0,0 @@ -import {Completion, CompletionContext, snippet} from '@codemirror/autocomplete'; - -import {capitalize} from '../../../lodash'; -import {DirectiveSyntaxFacet} from '../directive-facet'; - -export type YfmNoteType = 'info' | 'tip' | 'warning' | 'alert'; -export const yfmNoteTypes: readonly YfmNoteType[] = ['info', 'tip', 'warning', 'alert']; -export const yfmNoteSnippetTemplate = (type: YfmNoteType) => - `{% note ${type} %}\n\n#{}\n\n{% endnote %}\n\n` as const; -export const yfmNoteSnippets: Record> = { - info: snippet(yfmNoteSnippetTemplate('info')), - tip: snippet(yfmNoteSnippetTemplate('tip')), - warning: snippet(yfmNoteSnippetTemplate('warning')), - alert: snippet(yfmNoteSnippetTemplate('alert')), -}; - -export const yfmCutSnippetTemplate = '{% cut "#{title}" %}\n\n#{}\n\n{% endcut %}\n\n'; -export const yfmCutSnippet = snippet(yfmCutSnippetTemplate); - -export const yfmCutDirectiveSnippetTemplate = ':::cut [#{title}]\n#{}\n:::\n\n'; -export const yfmCutDirectiveSnippet = snippet(yfmCutDirectiveSnippetTemplate); - -export const directiveAutocomplete = { - autocomplete: (context: CompletionContext) => { - const directiveContext = context.state.facet(DirectiveSyntaxFacet); - - // TODO: add more actions and re-enable - // let word = context.matchBefore(/\/.*/); - // if (word) { - // return { - // from: word.from, - // options: [ - // ...yfmNoteTypes.map((type, index) => ({ - // label: `/yfm note ${type}`, - // displayLabel: `YFM Note ${capitalize(type)}`, - // type: 'text', - // apply: yfmNoteSnippets[type], - // boost: -index, - // })), - // { - // label: '/yfm cut', - // displayLabel: 'YFM Cut', - // type: 'text', - // apply: directiveFacet.shouldInsertDirectiveMarkup('yfmCut') - // ? yfmCutDirectiveSnippet - // : yfmCutSnippet, - // }, - // ], - // }; - // } - - const word = context.matchBefore(/^.*/); - - // if (allowEmptyRows && word?.text.startsWith('&')) { - // return { - // from: word.from, - // options: [ - // { - // label: ' ', - // displayLabel: i18n('snippet.text'), - // type: 'text', - // apply: emptyRowSnippet, - // }, - // ], - // }; - // } - - if (directiveContext.option !== 'only' && word?.text.startsWith('{%')) { - return { - from: word.from, - options: [ - ...yfmNoteTypes.map((type, index) => ({ - label: `{% note ${type}`, - displayLabel: capitalize(type), - type: 'text', - section: 'YFM Note', - apply: yfmNoteSnippets[type], - boost: -index, - })), - { - label: '{% cut', - displayLabel: 'YFM Cut', - type: 'text', - apply: directiveContext.shouldInsertDirectiveMarkup('yfmCut') - ? yfmCutDirectiveSnippet - : yfmCutSnippet, - }, - ], - }; - } - if (directiveContext.option !== 'disabled' && word?.text.startsWith(':')) { - const options: Completion[] = []; - - if (directiveContext.valueFor('yfmCut') !== 'disabled') { - options.push({ - label: ':::cut', - displayLabel: 'YFM Cut', - type: 'text', - apply: yfmCutDirectiveSnippet, - }); - } - - if (options.length) { - return {from: word.from, options}; - } - } - return null; - }, -}; diff --git a/src/markup/codemirror/autocomplete/index.ts b/src/markup/codemirror/autocomplete/index.ts index 0c40f5f9..d77b500e 100644 --- a/src/markup/codemirror/autocomplete/index.ts +++ b/src/markup/codemirror/autocomplete/index.ts @@ -1,18 +1,19 @@ -import {directiveAutocomplete} from './directive'; +import {mdAutocomplete} from '../yfm'; + import {emptyRowAutocomplete} from './emptyRow'; type GetAutocompleteConfig = { - allowEmptyRows?: boolean; + preserveEmptyRows?: boolean; }; -export const getAutocompleteConfig = ({allowEmptyRows}: GetAutocompleteConfig) => { +export const getAutocompleteConfig = ({preserveEmptyRows}: GetAutocompleteConfig) => { const autocompleteItems = []; - if (allowEmptyRows) { + if (preserveEmptyRows) { autocompleteItems.push(emptyRowAutocomplete); } - autocompleteItems.push(directiveAutocomplete); + autocompleteItems.push(mdAutocomplete); return autocompleteItems; }; diff --git a/src/markup/codemirror/yfm.ts b/src/markup/codemirror/yfm.ts index 2b7f6342..ccc6f15c 100644 --- a/src/markup/codemirror/yfm.ts +++ b/src/markup/codemirror/yfm.ts @@ -1,9 +1,13 @@ -import {CompletionSource} from '@codemirror/autocomplete'; +import {Completion, CompletionSource, snippet} from '@codemirror/autocomplete'; import {markdown, markdownLanguage} from '@codemirror/lang-markdown'; import type {Extension} from '@codemirror/state'; import {Tag, tags} from '@lezer/highlight'; import type {DelimiterType, MarkdownConfig} from '@lezer/markdown'; +import {capitalize} from '../../lodash'; + +import {DirectiveSyntaxFacet} from './directive-facet'; + export const customTags = { underline: Tag.define(), monospace: Tag.define(), @@ -60,6 +64,23 @@ const MarkedExtension = mdInlineFactory({ tag: customTags.marked, }); +export type YfmNoteType = 'info' | 'tip' | 'warning' | 'alert'; +export const yfmNoteTypes: readonly YfmNoteType[] = ['info', 'tip', 'warning', 'alert']; +export const yfmNoteSnippetTemplate = (type: YfmNoteType) => + `{% note ${type} %}\n\n#{}\n\n{% endnote %}\n\n` as const; +export const yfmNoteSnippets: Record> = { + info: snippet(yfmNoteSnippetTemplate('info')), + tip: snippet(yfmNoteSnippetTemplate('tip')), + warning: snippet(yfmNoteSnippetTemplate('warning')), + alert: snippet(yfmNoteSnippetTemplate('alert')), +}; + +export const yfmCutSnippetTemplate = '{% cut "#{title}" %}\n\n#{}\n\n{% endcut %}\n\n'; +export const yfmCutSnippet = snippet(yfmCutSnippetTemplate); + +export const yfmCutDirectiveSnippetTemplate = ':::cut [#{title}]\n#{}\n:::\n\n'; +export const yfmCutDirectiveSnippet = snippet(yfmCutDirectiveSnippetTemplate); + export interface LanguageData { autocomplete: CompletionSource; [key: string]: any; @@ -69,6 +90,79 @@ export interface YfmLangOptions { languageData?: LanguageData[]; } +export const mdAutocomplete: LanguageData = { + autocomplete: (context) => { + const directiveContext = context.state.facet(DirectiveSyntaxFacet); + + // TODO: add more actions and re-enable + // let word = context.matchBefore(/\/.*/); + // if (word) { + // return { + // from: word.from, + // options: [ + // ...yfmNoteTypes.map((type, index) => ({ + // label: `/yfm note ${type}`, + // displayLabel: `YFM Note ${capitalize(type)}`, + // type: 'text', + // apply: yfmNoteSnippets[type], + // boost: -index, + // })), + // { + // label: '/yfm cut', + // displayLabel: 'YFM Cut', + // type: 'text', + // apply: directiveFacet.shouldInsertDirectiveMarkup('yfmCut') + // ? yfmCutDirectiveSnippet + // : yfmCutSnippet, + // }, + // ], + // }; + // } + + const word = context.matchBefore(/^.*/); + if (directiveContext.option !== 'only' && word?.text.startsWith('{%')) { + return { + from: word.from, + options: [ + ...yfmNoteTypes.map((type, index) => ({ + label: `{% note ${type}`, + displayLabel: capitalize(type), + type: 'text', + section: 'YFM Note', + apply: yfmNoteSnippets[type], + boost: -index, + })), + { + label: '{% cut', + displayLabel: 'YFM Cut', + type: 'text', + apply: directiveContext.shouldInsertDirectiveMarkup('yfmCut') + ? yfmCutDirectiveSnippet + : yfmCutSnippet, + }, + ], + }; + } + if (directiveContext.option !== 'disabled' && word?.text.startsWith(':')) { + const options: Completion[] = []; + + if (directiveContext.valueFor('yfmCut') !== 'disabled') { + options.push({ + label: ':::cut', + displayLabel: 'YFM Cut', + type: 'text', + apply: yfmCutDirectiveSnippet, + }); + } + + if (options.length) { + return {from: word.from, options}; + } + } + return null; + }, +}; + export function yfmLang({languageData = []}: YfmLangOptions = {}): Extension { const mdSupport = markdown({ // defaultCodeLanguage: markdownLanguage, @@ -78,5 +172,9 @@ export function yfmLang({languageData = []}: YfmLangOptions = {}): Extension { extensions: [UnderlineExtension, MonospaceExtension, MarkedExtension], }); - return [mdSupport, languageData.map((item) => mdSupport.language.data.of(item))]; + return [ + mdSupport, + mdSupport.language.data.of(mdAutocomplete), + languageData.map((item) => mdSupport.language.data.of(item)), + ]; } diff --git a/src/markup/commands/helpers.ts b/src/markup/commands/helpers.ts index 1ab668dd..94629278 100644 --- a/src/markup/commands/helpers.ts +++ b/src/markup/commands/helpers.ts @@ -26,11 +26,7 @@ export function getBlockExtraLineBreaks( return {before: lineBreaksBefore, after: lineBreaksAfter}; } -export function replaceOrInsertAfter( - state: EditorState, - markup: string, - shouldGetBreaks?: boolean, -): TransactionSpec { +export function replaceOrInsertAfter(state: EditorState, markup: string): TransactionSpec { const selrange = state.selection.main; if (isFullLinesSelection(state.doc, selrange)) { const extraBreaks = getBlockExtraLineBreaks(state, { @@ -43,26 +39,14 @@ export function replaceOrInsertAfter( state.lineBreak.repeat(extraBreaks.after), ); } else { - let breaksAfter = 2; - - if (shouldGetBreaks) { - const extraBreaks = getBlockExtraLineBreaks(state, { - from: state.doc.lineAt(selrange.from), - to: state.doc.lineAt(selrange.to), - }); - - breaksAfter = extraBreaks.after; - } - - const insert = state.lineBreak.repeat(2) + markup + state.lineBreak.repeat(breaksAfter); - + const insert = state.lineBreak.repeat(2) + markup + state.lineBreak.repeat(2); const from = state.doc.lineAt(selrange.to).to; - const selAnchor = from + insert.length + breaksAfter; + const selAnchor = from + insert.length - 2; return {changes: {from, insert}, selection: {anchor: selAnchor}}; } } -export function isFullLinesSelection(doc: Text, range: SelectionRange): boolean { +function isFullLinesSelection(doc: Text, range: SelectionRange): boolean { const fromLine = doc.lineAt(range.from); const toLine = doc.lineAt(range.to); return range.from <= fromLine.from && range.to >= toLine.to; From b8cf4ba23ecca9f3ef88d732a9020173bf853296 Mon Sep 17 00:00:00 2001 From: Mikhail Pavlovich Date: Mon, 16 Dec 2024 17:49:23 +0300 Subject: [PATCH 08/15] fix PR --- src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts b/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts index 3b332bbd..09e9c793 100644 --- a/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts +++ b/src/extensions/base/BaseSchema/BaseSchemaSpecs/index.ts @@ -64,6 +64,10 @@ export const BaseSchemaSpecs: ExtensionAuto = (builder, }, fromMd: {tokenSpec: {name: BaseNode.Paragraph, type: 'block'}}, 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; From 0fa502933e541baca8e642bd31db2152908a0add 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 09/15] 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 56716cb8..937264f3 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, @@ -80,6 +81,7 @@ export type PlaygroundProps = { breaks?: boolean; linkify?: boolean; linkifyTlds?: string | string[]; + placeholderOptions?: WysiwygPlaceholderOptions; sanitizeHtml?: boolean; prepareRawMarkup?: boolean; splitModeOrientation?: 'horizontal' | 'vertical' | false; @@ -141,6 +143,7 @@ export const Playground = React.memo((props) => { wysiwygCommandMenuConfig, markupConfigExtensions, markupToolbarConfig, + placeholderOptions, escapeConfig, enableSubmitInPreview, hidePreviewAfterSubmit, @@ -187,6 +190,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 17e005db..d82dbe14 100644 --- a/src/bundle/types.ts +++ b/src/bundle/types.ts @@ -28,6 +28,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; preserveEmptyRows?: boolean; @@ -152,6 +163,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 243004e8..07d12fd8 100644 --- a/src/bundle/useMarkdownEditor.ts +++ b/src/bundle/useMarkdownEditor.ts @@ -66,6 +66,7 @@ export function useMarkdownEditor( return true; }, preserveEmptyRows: preserveEmptyRows, + placeholderOptions: wysiwygConfig.placeholderOptions, mdBreaks: breaks, fileUploadHandler: uploadFile, needToSetDimensionsForUploadedImages, diff --git a/src/bundle/wysiwyg-preset.ts b/src/bundle/wysiwyg-preset.ts index 4be28611..ca2bc1bd 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; @@ -28,6 +28,7 @@ export type BundlePresetOptions = ExtensionsOptions & mdBreaks?: boolean; preserveEmptyRows?: boolean; fileUploadHandler?: FileUploadHandler; + placeholderOptions?: WysiwygPlaceholderOptions; /** * If we need to set dimensions for uploaded images * @@ -64,9 +65,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'); }, preserveEmptyRows: opts.preserveEmptyRows, ...opts.baseSchema, From b6c39876c1657131ea52637f3dbeabad1a3db03d Mon Sep 17 00:00:00 2001 From: Yuriy Demidov Date: Tue, 17 Dec 2024 19:29:59 +0300 Subject: [PATCH 10/15] 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 5427ef7693c11051c8fdcbfb41da958823aa5b78 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 11/15] 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 cef87383dd56dcb91afbbc00a3f8238d526c99aa Mon Sep 17 00:00:00 2001 From: Kirill Kharitonov Date: Tue, 17 Dec 2024 18:15:48 +0100 Subject: [PATCH 12/15] 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 1c2d29890182d2105bb3614cf726f3c8568ed2d2 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 13/15] 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 8ed557d3fa67e381552b2b9a088e44b1d2650fa8 Mon Sep 17 00:00:00 2001 From: Mikhail Pavlovich Date: Mon, 23 Dec 2024 13:09:31 +0300 Subject: [PATCH 14/15] fix PR --- demo/components/Playground.tsx | 2 -- src/bundle/Editor.ts | 14 +++++++++++--- src/bundle/types.ts | 10 ++++++---- src/bundle/useMarkdownEditor.ts | 5 +++-- src/core/Editor.ts | 3 ++- src/core/ExtensionsManager.ts | 7 ++++--- .../{emptyRowParser.ts => emptyRowTransformer.ts} | 0 .../ProseMirrorTransformer/getTransformers.ts | 2 +- src/markup/codemirror/create.ts | 11 ++++++++++- 9 files changed, 37 insertions(+), 17 deletions(-) rename src/core/markdown/ProseMirrorTransformer/{emptyRowParser.ts => emptyRowTransformer.ts} (100%) diff --git a/demo/components/Playground.tsx b/demo/components/Playground.tsx index 937264f3..be7bcb8d 100644 --- a/demo/components/Playground.tsx +++ b/demo/components/Playground.tsx @@ -196,8 +196,6 @@ export const Playground = React.memo((props) => { experimental: { ...experimental, directiveSyntax, - }, - md: { preserveEmptyRows: preserveEmptyRows, }, prepareRawMarkup: prepareRawMarkup diff --git a/src/bundle/Editor.ts b/src/bundle/Editor.ts index 1471b47c..84f032c7 100644 --- a/src/bundle/Editor.ts +++ b/src/bundle/Editor.ts @@ -4,6 +4,8 @@ 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 { @@ -125,6 +127,7 @@ export type EditorOptions = Pick< renderStorage: ReactRenderStorage; preset: EditorPreset; directiveSyntax: DirectiveSyntaxContext; + pmTransformers: TransformFn[]; }; /** @internal */ @@ -140,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']; @@ -249,7 +254,7 @@ export class EditorImpl extends SafeEventEmitter implements EditorI mdPreset, initialContent: this.#markup, extensions: this.#extensions, - pmTransformers: this.#mdOptions.pmTransformers, + pmTransformers: this.#pmTransformers, allowHTML: this.#mdOptions.html, linkify: this.#mdOptions.linkify, linkifyTlds: this.#mdOptions.linkifyTlds, @@ -281,10 +286,11 @@ export class EditorImpl extends SafeEventEmitter implements EditorI extensions: this.#markupConfig.extensions, disabledExtensions: this.#markupConfig.disabledExtensions, keymaps: this.#markupConfig.keymaps, + preserveEmptyRows: this.#preserveEmptyRows, yfmLangOptions: { languageData: getAutocompleteConfig({ - preserveEmptyRows: this.#mdOptions.preserveEmptyRows, - }), + preserveEmptyRows: this.#preserveEmptyRows, + }).concat(this.#markupConfig?.languageData || []), }, autocompletion: this.#markupConfig.autocompletion, directiveSyntax: this.directiveSyntax, @@ -336,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}; @@ -348,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/types.ts b/src/bundle/types.ts index d82dbe14..a25fda69 100644 --- a/src/bundle/types.ts +++ b/src/bundle/types.ts @@ -2,8 +2,6 @@ import type {ReactNode} from 'react'; -import {TransformFn} from 'src/core/markdown/ProseMirrorTransformer'; - import type {MarkupString} from '../common'; import type {EscapeConfig, Extension} from '../core'; import type {CreateCodemirrorParams, YfmLangOptions} from '../markup'; @@ -41,11 +39,9 @@ export type WysiwygPlaceholderOptions = { export type MarkdownEditorMdOptions = { html?: boolean; - preserveEmptyRows?: boolean; breaks?: boolean; linkify?: boolean; linkifyTlds?: string | string[]; - pmTransformers?: TransformFn[]; }; export type MarkdownEditorInitialOptions = { @@ -108,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 07d12fd8..c515b9cb 100644 --- a/src/bundle/useMarkdownEditor.ts +++ b/src/bundle/useMarkdownEditor.ts @@ -34,7 +34,7 @@ export function useMarkdownEditor( } = props; const breaks = md.breaks ?? props.breaks; - const preserveEmptyRows = md.preserveEmptyRows; + const preserveEmptyRows = experimental.preserveEmptyRows; const preset: MarkdownEditorPreset = props.preset ?? 'full'; const renderStorage = new ReactRenderStorage(); const uploadFile = handlers.uploadFile ?? props.fileUploadHandler; @@ -79,18 +79,19 @@ export function useMarkdownEditor( } } }; + return new EditorImpl({ ...props, preset, renderStorage, directiveSyntax, + pmTransformers, md: { ...md, breaks, html: md.html ?? props.allowHTML, linkify: md.linkify ?? props.linkify, linkifyTlds: md.linkifyTlds ?? props.linkifyTlds, - pmTransformers, }, initial: { ...initial, diff --git a/src/core/Editor.ts b/src/core/Editor.ts index 92c5bfaa..a7f44344 100644 --- a/src/core/Editor.ts +++ b/src/core/Editor.ts @@ -93,8 +93,9 @@ export class WysiwygEditor implements CommonEditor, ActionStorage { actions, } = ExtensionsManager.process(extensions, { // "breaks" option only affects the renderer, but not the parser - mdOpts: {pmTransformers, html: allowHTML, linkify, breaks: true, preset: mdPreset}, + 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 5c484899..7ce5de49 100644 --- a/src/core/ExtensionsManager.ts +++ b/src/core/ExtensionsManager.ts @@ -23,8 +23,9 @@ type ExtensionsManagerParams = { }; type ExtensionsManagerOptions = { - mdOpts?: MarkdownIt.Options & {preset?: PresetName; pmTransformers?: TransformFn[]}; + mdOpts?: MarkdownIt.Options & {preset?: PresetName}; linkifyTlds?: string | string[]; + pmTransformers?: TransformFn[]; }; export class ExtensionsManager { @@ -65,8 +66,8 @@ export class ExtensionsManager { this.#mdForText.linkify.tlds(options.linkifyTlds, true); } - if (options.mdOpts?.pmTransformers) { - this.#pmTransformers = options.mdOpts?.pmTransformers; + if (options.pmTransformers) { + this.#pmTransformers = options.pmTransformers; } // TODO: add prefilled context diff --git a/src/core/markdown/ProseMirrorTransformer/emptyRowParser.ts b/src/core/markdown/ProseMirrorTransformer/emptyRowTransformer.ts similarity index 100% rename from src/core/markdown/ProseMirrorTransformer/emptyRowParser.ts rename to src/core/markdown/ProseMirrorTransformer/emptyRowTransformer.ts diff --git a/src/core/markdown/ProseMirrorTransformer/getTransformers.ts b/src/core/markdown/ProseMirrorTransformer/getTransformers.ts index d9e05884..552229c7 100644 --- a/src/core/markdown/ProseMirrorTransformer/getTransformers.ts +++ b/src/core/markdown/ProseMirrorTransformer/getTransformers.ts @@ -1,5 +1,5 @@ // TODO: add a new method to the ExtensionBuilder -import {transformEmptyParagraph} from './emptyRowParser'; +import {transformEmptyParagraph} from './emptyRowTransformer'; import {TransformFn} from '.'; diff --git a/src/markup/codemirror/create.ts b/src/markup/codemirror/create.ts index 75ffb18a..58099bf7 100644 --- a/src/markup/codemirror/create.ts +++ b/src/markup/codemirror/create.ts @@ -76,6 +76,7 @@ export type CreateCodemirrorParams = { yfmLangOptions?: YfmLangOptions; autocompletion?: Autocompletion; directiveSyntax: DirectiveSyntaxContext; + preserveEmptyRows: boolean; }; export function createCodemirror(params: CreateCodemirrorParams) { @@ -97,6 +98,7 @@ export function createCodemirror(params: CreateCodemirrorParams) { parseHtmlOnPaste, parseInsertedUrlAsImage, directiveSyntax, + preserveEmptyRows, } = params; const extensions: Extension[] = [gravityTheme, placeholder(placeholderContent)]; @@ -123,7 +125,6 @@ export function createCodemirror(params: CreateCodemirrorParams) { {key: f.toCM(A.CodeBlock)!, run: withLogger(ActionName.code_block, wrapToCodeBlock)}, {key: f.toCM(A.Cut)!, run: withLogger(ActionName.yfm_cut, wrapToYfmCut)}, {key: f.toCM(A.Note)!, run: withLogger(ActionName.yfm_note, wrapToYfmNote)}, - {key: f.toCM(A.EmptyRow)!, run: withLogger(ActionName.emptyRow, insertEmptyRow)}, { key: f.toCM(A.Cancel)!, preventDefault: true, @@ -230,6 +231,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({ From 51368a253772fc21887f99c1fcf04dd8a114efa9 Mon Sep 17 00:00:00 2001 From: Mikhail Pavlovich Date: Mon, 23 Dec 2024 17:10:31 +0300 Subject: [PATCH 15/15] fix PR --- src/bundle/config/action-names.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/bundle/config/action-names.ts b/src/bundle/config/action-names.ts index 738743ec..cb4ab827 100644 --- a/src/bundle/config/action-names.ts +++ b/src/bundle/config/action-names.ts @@ -21,22 +21,6 @@ const names = [ 'heading5', 'heading6', 'emptyRow', - 'bulletList', - 'orderedList', - 'liftListItem', - 'sinkListItem', - 'checkbox', - 'link', - 'quote', - 'yfm_cut', - 'yfm_note', - 'yfm_block', - 'yfm_html_block', - 'yfm_layout', - 'table', - 'code_inline', - 'code_block', - 'image', /** @deprecated use horizontalRule */ 'horizontalrule', 'horizontalRule',