From d2a01a478584dde7f1ac4d30fa39ab8a1381556d Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Tue, 18 Mar 2025 10:27:27 +0100 Subject: [PATCH 1/5] feat(Lists): added sinkOnlySelectedListItem --- src/extensions/markdown/Lists/actions.ts | 8 +- .../markdown/Lists/commands.test.ts | 222 ++++++++++++++++++ src/extensions/markdown/Lists/commands.ts | 33 ++- src/extensions/markdown/Lists/index.ts | 8 +- src/extensions/markdown/Lists/ist.js | 100 ++++++++ 5 files changed, 361 insertions(+), 10 deletions(-) create mode 100644 src/extensions/markdown/Lists/commands.test.ts create mode 100644 src/extensions/markdown/Lists/ist.js diff --git a/src/extensions/markdown/Lists/actions.ts b/src/extensions/markdown/Lists/actions.ts index ad05b27d9..b97effe9b 100644 --- a/src/extensions/markdown/Lists/actions.ts +++ b/src/extensions/markdown/Lists/actions.ts @@ -1,8 +1,8 @@ -import {liftListItem, sinkListItem} from 'prosemirror-schema-list'; +import {liftListItem} from 'prosemirror-schema-list'; import type {ActionSpec, ExtensionDeps} from '../../../core'; -import {toList} from './commands'; +import {sinkOnlySelectedListItem, toList} from './commands'; import {ListNode} from './const'; import {blType, isIntoListOfType, liType, olType} from './utils'; @@ -34,8 +34,8 @@ export const actions = { sinkListItem: ({schema}: ExtensionDeps): ActionSpec => { return { - isEnable: sinkListItem(liType(schema)), - run: sinkListItem(liType(schema)), + isEnable: sinkOnlySelectedListItem(liType(schema)), + run: sinkOnlySelectedListItem(liType(schema)), }; }, }; diff --git a/src/extensions/markdown/Lists/commands.test.ts b/src/extensions/markdown/Lists/commands.test.ts new file mode 100644 index 000000000..ec00ed71c --- /dev/null +++ b/src/extensions/markdown/Lists/commands.test.ts @@ -0,0 +1,222 @@ +import type {Node} from 'prosemirror-model'; +import {liftListItem, splitListItem, wrapInList} from 'prosemirror-schema-list'; +import { + type Command, + EditorState, + NodeSelection, + Selection, + TextSelection, +} from 'prosemirror-state'; +import {blockquote, doc, eq, li, ol, p, schema, ul} from 'prosemirror-test-builder'; + +import {sinkOnlySelectedListItem} from 'src/extensions/markdown/Lists/commands'; +import ist from 'src/extensions/markdown/Lists/ist'; + +function selFor(doc: Node) { + const a = (doc as any).tag.a, + b = (doc as any).tag.b; + if (a !== null) { + const $a = doc.resolve(a); + if ($a.parent.inlineContent) + return new TextSelection($a, b != null ? doc.resolve(b) : undefined); + else return new NodeSelection($a); + } + return Selection.atStart(doc); +} + +function apply(doc: Node, command: Command, result: Node | null) { + let state = EditorState.create({doc, selection: selFor(doc)}); + command(state, (tr) => (state = state.apply(tr))); + ist(state.doc, result || doc, eq); + if (result && (result as any).tag.a != null) ist(state.selection, selFor(result), eq); +} + +describe('wrapInList', () => { + const wrap = wrapInList(schema.nodes.bullet_list); + const wrapo = wrapInList(schema.nodes.ordered_list); + + it('can wrap a paragraph', () => apply(doc(p('foo')), wrap, doc(ul(li(p('foo')))))); + + it('can wrap a nested paragraph', () => + apply(doc(blockquote(p('foo'))), wrapo, doc(blockquote(ol(li(p('foo'))))))); + + it('can wrap multiple paragraphs', () => + apply( + doc(p('foo'), p('bar'), p('baz')), + wrap, + doc(p('foo'), ul(li(p('bar')), li(p('baz')))), + )); + + it("doesn't wrap the first paragraph in a list item", () => + apply(doc(ul(li(p('foo')))), wrap, null)); + + it("doesn't wrap the first para in a different type of list item", () => + apply(doc(ol(li(p('foo')))), wrapo, null)); + + it('does wrap the second paragraph in a list item', () => + apply(doc(ul(li(p('foo'), p('bar')))), wrap, doc(ul(li(p('foo'), ul(li(p('bar')))))))); + + it('joins with the list item above when wrapping its first paragraph', () => + apply( + doc(ul(li(p('foo')), li(p('bar')), li(p('baz')))), + wrapo, + doc(ul(li(p('foo'), ol(li(p('bar')))), li(p('baz')))), + )); + + it('only splits items where valid', () => + apply( + doc(p('one'), ol(li('two')), p('three')), + wrapo, + doc(ol(li(p('one'), ol(li('two'))), li(p('three')))), + )); +}); + +describe('splitListItem', () => { + const split = splitListItem(schema.nodes.list_item); + + it('has no effect outside of a list', () => apply(doc(p('foobar')), split, null)); + + it('has no effect on the top level', () => apply(doc('', p('foobar')), split, null)); + + it('can split a list item', () => + apply(doc(ul(li(p('foobar')))), split, doc(ul(li(p('foo')), li(p('bar')))))); + + it('can split a list item at the end', () => + apply(doc(ul(li(p('foobar')))), split, doc(ul(li(p('foobar')), li(p()))))); + + it('deletes selected content', () => + apply(doc(ul(li(p('foobar')))), split, doc(ul(li(p('foo')), li(p('r')))))); + + it('splits when lifting from a nested list', () => + apply( + doc(ul(li(p('a'), ul(li(p('b')), li(p(''))))), p('x')), + split, + doc(ul(li(p('a'), ul(li(p('b')))), li(p(''))), p('x')), + )); + + it('can lift from a continued nested list item', () => + apply( + doc(ul(li(p('a'), ul(li(p('b')), li(p('ok'), p(''))))), p('x')), + split, + doc(ul(li(p('a'), ul(li(p('b')), li(p('ok')))), li(p(''))), p('x')), + )); + + it('correctly lifts an entirely empty sublist', () => + apply( + doc(ul(li(p('one'), ul(li(p(''))), p('two')))), + split, + doc(ul(li(p('one')), li(p('')), li(p('two')))), + )); +}); + +describe('liftListItem', () => { + const lift = liftListItem(schema.nodes.list_item); + + it('can lift from a nested list', () => + apply( + doc(ul(li(p('hello'), ul(li(p('one')), li(p('two')))))), + lift, + doc(ul(li(p('hello')), li(p('one'), ul(li(p('two')))))), + )); + + it('can lift two items from a nested list', () => + apply( + doc(ul(li(p('hello'), ul(li(p('one')), li(p('two')))))), + lift, + doc(ul(li(p('hello')), li(p('one')), li(p('two')))), + )); + + it('can lift two items from a nested three-item list', () => + apply( + doc(ul(li(p('hello'), ul(li(p('one')), li(p('two')), li(p('three')))))), + lift, + doc(ul(li(p('hello')), li(p('one')), li(p('two'), ul(li(p('three')))))), + )); + + it('can lift an item out of a list', () => + apply(doc(p('a'), ul(li(p('b'))), p('c')), lift, doc(p('a'), p('b'), p('c')))); + + it('can lift two items out of a list', () => + apply( + doc(p('a'), ul(li(p('b')), li(p('c'))), p('d')), + lift, + doc(p('a'), p('b'), p('c'), p('d')), + )); + + it('can lift three items from the middle of a list', () => + apply( + doc(ul(li(p('a')), li(p('b')), li(p('c')), li(p('d')), li(p('e')))), + lift, + doc(ul(li(p('a'))), p('b'), p('c'), p('d'), ul(li(p('e')))), + )); + + it('can lift the first item from a list', () => + apply( + doc(ul(li(p('a')), li(p('b')), li(p('c')))), + lift, + doc(p('a'), ul(li(p('b')), li(p('c')))), + )); + + it('can lift the last item from a list', () => + apply( + doc(ul(li(p('a')), li(p('b')), li(p('c')))), + lift, + doc(ul(li(p('a')), li(p('b'))), p('c')), + )); + + it('joins adjacent lists when lifting an item with subitems', () => + apply( + doc(ol(li(p('a'), ol(li(p('b'), ol(li(p('c')))), li(p('d')))), li(p('e')))), + lift, + doc(ol(li(p('a')), li(p('b'), ol(li(p('c')), li(p('d')))), li(p('e')))), + )); + + it('only joins adjacent lists when lifting if their types match', () => + apply( + doc(ol(li(p('a'), ul(li(p('b'), ol(li(p('c')))), li(p('d')))))), + lift, + doc(ol(li(p('a')), li(p('b'), ol(li(p('c'))), ul(li(p('d')))))), + )); +}); + +describe('sinkListItem', () => { + const sink = sinkOnlySelectedListItem(schema.nodes.list_item); + + // it('can wrap a simple item in a list', () => + // apply( + // doc(ul(li(p('one')), li(p('two')), li(p('three')))), + // sink, + // doc(ul(li(p('one'), ul(li(p('two')))), li(p('three')))), + // )); + // + // it("won't wrap the first item in a sublist", () => + // apply(doc(ul(li(p('one')), li(p('two')), li(p('three')))), sink, null)); + // + // it("will move an item's content into the item above", () => + // apply( + // doc(ul(li(p('one')), li(p('...'), ul(li(p('two')))), li(p('three')))), + // sink, + // doc(ul(li(p('one')), li(p('...'), ul(li(p('two')), li(p('three')))))), + // )); + // + // it('sel 1, can wrap a simple item in a list', () => + // apply( + // doc(ul(li(p('one')), li(p('two')), li(p('three')))), + // sink, + // doc(ul(li(p('one'), ul(li(p('two')), li(p('three')))))), + // )); + // + // it('move selected', () => + // apply( + // doc(ul(li(p('one')), li(p('two'), ul(li(p('three')))))), + // sink, + // doc(ul(li(p('one'), ul(li(p('two'), ul(li(p('three')))))))), + // )); + + it('move only selected', () => + apply( + doc(ul(li(p('one')), li(p('two'), ul(li(p('three')))))), + sink, + doc(ul(li(p('one'), ul(li(p('two')), li(p('three')))))), + )); +}); diff --git a/src/extensions/markdown/Lists/commands.ts b/src/extensions/markdown/Lists/commands.ts index df961d7dc..c79d8ea78 100644 --- a/src/extensions/markdown/Lists/commands.ts +++ b/src/extensions/markdown/Lists/commands.ts @@ -1,6 +1,10 @@ import type {NodeType} from 'prosemirror-model'; -import {wrapInList} from 'prosemirror-schema-list'; -import type {Command} from 'prosemirror-state'; +import { + sinkListItem as defaultSinkListItem, + liftListItem, + wrapInList, +} from 'prosemirror-schema-list'; +import type {Command, EditorState, Transaction} from 'prosemirror-state'; import {joinPreviousBlock} from '../../../commands/join'; @@ -23,3 +27,28 @@ export const joinPrevList = joinPreviousBlock({ checkPrevNode: isListNode, skipNode: isListOrItemNode, }); + +export function sinkOnlySelectedListItem(itemType: NodeType): Command { + const sink = defaultSinkListItem(itemType); + const lift = liftListItem(itemType); + + return (state: EditorState, dispatch?: (tr: Transaction) => void): boolean => { + let tr: Transaction = state.tr; + const sinkResult = sink(state, (transaction) => { + tr = transaction; + }); + if (!sinkResult) { + return false; + } + // TODO lift for nested bullet или order + if (!tr) { + lift(state); + } + + if (dispatch) { + dispatch(tr); + } + + return true; + }; +} diff --git a/src/extensions/markdown/Lists/index.ts b/src/extensions/markdown/Lists/index.ts index 0875c81da..e6f0b9afe 100644 --- a/src/extensions/markdown/Lists/index.ts +++ b/src/extensions/markdown/Lists/index.ts @@ -1,11 +1,11 @@ -import {liftListItem, sinkListItem, splitListItem} from 'prosemirror-schema-list'; +import {liftListItem, splitListItem} from 'prosemirror-schema-list'; import type {Action, ExtensionAuto, Keymap} from '../../../core'; import {withLogAction} from '../../../utils/keymap'; import {ListsSpecs, blType, liType, olType} from './ListsSpecs'; import {actions} from './actions'; -import {joinPrevList, toList} from './commands'; +import {joinPrevList, sinkOnlySelectedListItem, toList} from './commands'; import {ListAction} from './const'; import {ListsInputRulesExtension, type ListsInputRulesOptions} from './inputrules'; import {collapseListsPlugin} from './plugins/CollapseListsPlugin'; @@ -29,11 +29,11 @@ export const Lists: ExtensionAuto = (builder, opts) => { if (olKey) bindings[olKey] = withLogAction('orderedList', toList(olType(schema))); return { - Tab: sinkListItem(liType(schema)), + Tab: sinkOnlySelectedListItem(liType(schema)), 'Shift-Tab': liftListItem(liType(schema)), 'Mod-[': liftListItem(liType(schema)), - 'Mod-]': sinkListItem(liType(schema)), + 'Mod-]': sinkOnlySelectedListItem(liType(schema)), ...bindings, }; diff --git a/src/extensions/markdown/Lists/ist.js b/src/extensions/markdown/Lists/ist.js new file mode 100644 index 000000000..64088396d --- /dev/null +++ b/src/extensions/markdown/Lists/ist.js @@ -0,0 +1,100 @@ +var opNames = [ + '==', + function (a, b) { + return a == b; + }, + '!=', + function (a, b) { + return a != b; + }, + '===', + function (a, b) { + return a === b; + }, + '!==', + function (a, b) { + return a !== b; + }, + '<', + function (a, b) { + return a < b; + }, + '>=', + function (a, b) { + return a >= b; + }, + '>', + function (a, b) { + return a > b; + }, + '<=', + function (a, b) { + return a <= b; + }, +]; +var ops = {}; +for (var i = 0; i < opNames.length; i += 2) ops[opNames[i]] = opNames[i + 1]; + +function message(a, b, compare) { + if (!compare || typeof compare == 'string') { + var index = opNames.indexOf(compare || '=='); + return a + ' ' + opNames[index + (index % 4 ? -2 : 2)] + ' ' + b; + } + return '!' + compare.name + '(' + a + ', ' + b + ')'; +} + +function ist(a, b, compare) { + if (arguments.length == 1) { + if (!a) throw new ist.Failure('!' + a, 'ist'); + } else { + var cmpFn = compare; + if (typeof compare == 'string') { + if (!(cmpFn = ops[compare])) throw new RangeError('Unknow operator ' + compare); + } else if (!compare) { + cmpFn = ops['==']; + } + if (!cmpFn(a, b)) { + var cmpName = (typeof compare == 'string' ? compare : compare && compare.name) || '=='; + throw new ist.Failure(message(a, b, compare), 'ist'); + } + } +} + +ist.Failure = function (message, src) { + this.message = message; + if (Error.captureStackTrace) Error.captureStackTrace(this); + else this.stack = new Error(message).stack; + if (this.stack) { + var lines = this.stack.split('\n'), + re = new RegExp('\\b' + src + '\\b'); + for (var i = 0; i < lines.length - 1; i++) { + if (re.test(lines[i]) && !/\bFailure\b/.test(lines[i])) { + this.message += ' ' + lines[i + 1].trim(); + break; + } + } + } +}; +ist.Failure.prototype = Object.create(Error.prototype); + +ist.throws = function throws(f, expected) { + let threw = true; + try { + f(); + threw = false; + } catch (e) { + var matches = !expected + ? true + : expected.test + ? expected.test(e.message) + : typeof expected == 'string' + ? e.message == expected + : expected(e); + if (!matches) throw e; + } + if (!threw) throw new ist.Failure('Did not throw', 'throws'); +}; + +ist.default = ist; + +export default ist; From e1710a1916901d5c6a1d105648f278ec2666335c Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Fri, 21 Mar 2025 09:48:08 +0100 Subject: [PATCH 2/5] feat(Lists): added sinkOnlySelectedListItem --- demo/defaults/content.ts | 557 +++++++++++----------- src/extensions/markdown/Lists/commands.ts | 107 ++++- src/extensions/markdown/Lists/index.ts | 4 +- 3 files changed, 375 insertions(+), 293 deletions(-) diff --git a/demo/defaults/content.ts b/demo/defaults/content.ts index b16fb0b73..54fee1d3e 100644 --- a/demo/defaults/content.ts +++ b/demo/defaults/content.ts @@ -1,278 +1,301 @@ +// export const markup = ` +//   +// +// Welcome to the editor! Start typing the character \`/\` +// +// ![Markdown Editor](https://github.com/user-attachments/assets/0b4e5f65-54cf-475f-9c68-557a4e9edb46 =700x) +// +// ## Markdown WYSIWYG and markup editor +// +// MarkdownEditor is a powerful tool for working with Markdown, which combines WYSIWYG and Markup modes. This means that you can create and edit content in a convenient visual mode, as well as have full control over the markup. +// +// The editor supports following formats: +// +// * WYSIWYG +// +// * markup +// +// Click on the gear in the upper right corner to change the mode and see the \`md\` markup. +// +// ### Various blocks included +// +// {% cut "Combine different blocks" %} +// +// {% note info "Block for notes, tips, warnings, and alerts" %} +// +// Depending on the content, notes with different titles and formats are used: +// +// * Note: provides additional information. +// * Tip: offers a recommendation. +// * Warning: issues a warning. +// * Alert: indicates a restriction. +// +// {% endnote %} +// +// > [Improve](https://github.com/gravity-ui/markdown-editor/blob/main/docs/how-to-add-preview.md) the editor interface +// > +// > *improved by you* +// +// {% endcut %} +// +// Or write your extension using a [convenient api](https://github.com/gravity-ui/markdown-editor/blob/main/docs/how-to-create-extension.md) +// +// ### A user-friendly API is provided +// +// Easily connect to your React app with a hook: +// +// \`\`\`plaintext +// import React from 'react'; +// import { useMarkdownEditor, MarkdownEditorView } from '@gravity-ui/markdown-editor'; +// import { toaster } from '@gravity-ui/uikit/toaster-singleton'; +// +// function Editor({ onSubmit }) { +// const editor = useMarkdownEditor({ allowHTML: false }); +// +// React.useEffect(() => { +// function submitHandler() { +// // Serialize current content to markdown markup +// const value = editor.getValue(); +// onSubmit(value); +// } +// +// editor.on('submit', submitHandler); +// return () => { +// editor.off('submit', submitHandler); +// }; +// }, [onSubmit]); +// +// return ; +// } +// \`\`\` +// +// ### Convenient UX control is equipped +// +// #### Hot keys +// +// {% list tabs %} +// +// - WYSIWYG mode +// +// +// +// |Formatting|Windows Shortcut|Mac OS Shortcut| +// |:---|:---|:---| +// |Bold text|Ctrl \\+ B|⌘ \\+ B| +// |Italic|Ctrl \\+ I|⌘ \\+ I| +// |Underlined text|Ctrl \\+ U|⌘ \\+ U| +// |Strikethrough text|Ctrl \\+ Shift \\+ S|⌘ \\+ Shift \\+ S| +// +// - Markup mode +// +// +// +// |Formatting|Markup|Result| +// |:---|:---|:---| +// |Bold text|\`**Bold**\`|**Bold**| +// |Italic|\`*Italic*\`|*Italic*| +// |Underlined text|\`++Underlined++\`|++Underlined++| +// |Strikethrough text|\`~~Strikethrough~~\`|~~Strikethrough~~| +// +// {% endlist %} +// +// #### Context menu +// +// Select this text and you will see **a context menu**. +// +// #### Auto-conversion +// +// Quickly create blocks by entering characters that will be replaced by blocks. For example, the automatic conversion of \`-\` and space creates a list, \`>\` and space creates a quote. Try it out. +// +// --- +// +// ### Current and future features +// +// [X] Some already finished things +// +// [ ] VS Code plugin +// +// [ ] Mobile version +// +// ### And a multitude of other functionalities :sweat_smile: :fire: +// +// See +// +// # More examples {#anchor} +// +// {% cut "Headings" %} +// +// # Heading 1 +// +// ## Heading 2 +// +// ### Heading 3 +// +// #### Heading 4 +// +// ##### Heading 5 +// +// ###### Heading 6 +// +// {% endcut %} +// +// {% cut "This is a cut heading" %} +// +// **A** *here* ~~it~~ ++is awesome++ ^c^~o~^n^~t~^e^~n~^t^ +// +// > Done deal - deal done \`(quote)\` +// +// {% endcut %} +// +// {% cut "Formulas" %} +// +// This is an inline formula: $\\sqrt{3x-1}+(1+x)^2$ +// +// And here is a block formula: +// +// $$f(\\relax{x}) = \\int_{-\\infty}^\\infty +// \\hat f(\\xi)\\,e^{2 \\pi i \\xi x} +// \\,d\\xi +// $$ +// +// *Click on the formula to edit it* +// +// {% endcut %} +// +// --- +// +// #| +// || +// +// Category +// +// | +// +// Subcategory +// +// | +// +// Description +// +// || +// || +// +// **Technology** +// +// | +// +// ==Programming== +// +// | +// +// ***Innovative coding techniques and tools*** +// +// || +// || +// +// **Science** +// +// | +// +// ==Physics== +// +// | +// +// Understanding the laws of the universe and fundamental forces +// +// || +// || +// +// **Literature** +// +// | +// +// ==Fiction== +// +// | +// +// Exploring imaginary worlds and character-driven stories +// +// || +// || +// +// **Education** +// +// | +// +// ==E-learning== +// +// | +// +// !!!!!!!!!!!!!!!!!!!!!!!! +// +// New approaches to learning in the digital age +// +// || +// || +// +// **Health** +// +// | +// +// ==Nutrition== +// +// | +// +// ~~++Balanced diets++~~ for a healthy lifestyle +// +// || +// |# +// +// --- +// +// {% note info "Attention, please!" %} +// +// * Thank +// +// 1. you +// +// 2. for +// +// 1. your +// +// 3. attention +// +// * (nested lists) +// +// > > > Quotes +// > > +// > > Nested +// > +// > As well +// +// And ##monospace## can be **##com##**##bined\\*## +// +// {% endnote %} +// +// --- +// +// `.trim(); + +// FIXME: @makhnatkin return after debug export const markup = ` -  +1. aa -Welcome to the editor! Start typing the character \`/\` +2. bb -![Markdown Editor](https://github.com/user-attachments/assets/0b4e5f65-54cf-475f-9c68-557a4e9edb46 =700x) + 1. cc -## Markdown WYSIWYG and markup editor + 2. dd -MarkdownEditor is a powerful tool for working with Markdown, which combines WYSIWYG and Markup modes. This means that you can create and edit content in a convenient visual mode, as well as have full control over the markup. + 1. ee -The editor supports following formats: + 2. ss -* WYSIWYG + 3. zz -* markup + 4. ww -Click on the gear in the upper right corner to change the mode and see the \`md\` markup. - -### Various blocks included - -{% cut "Combine different blocks" %} - -{% note info "Block for notes, tips, warnings, and alerts" %} - -Depending on the content, notes with different titles and formats are used: - -* Note: provides additional information. -* Tip: offers a recommendation. -* Warning: issues a warning. -* Alert: indicates a restriction. - -{% endnote %} - -> [Improve](https://github.com/gravity-ui/markdown-editor/blob/main/docs/how-to-add-preview.md) the editor interface -> -> *improved by you* - -{% endcut %} - -Or write your extension using a [convenient api](https://github.com/gravity-ui/markdown-editor/blob/main/docs/how-to-create-extension.md) - -### A user-friendly API is provided - -Easily connect to your React app with a hook: - -\`\`\`plaintext -import React from 'react'; -import { useMarkdownEditor, MarkdownEditorView } from '@gravity-ui/markdown-editor'; -import { toaster } from '@gravity-ui/uikit/toaster-singleton'; - -function Editor({ onSubmit }) { - const editor = useMarkdownEditor({ allowHTML: false }); - - React.useEffect(() => { - function submitHandler() { - // Serialize current content to markdown markup - const value = editor.getValue(); - onSubmit(value); - } - - editor.on('submit', submitHandler); - return () => { - editor.off('submit', submitHandler); - }; - }, [onSubmit]); - - return ; -} -\`\`\` - -### Convenient UX control is equipped - -#### Hot keys - -{% list tabs %} - -- WYSIWYG mode - - - - |Formatting|Windows Shortcut|Mac OS Shortcut| - |:---|:---|:---| - |Bold text|Ctrl \\+ B|⌘ \\+ B| - |Italic|Ctrl \\+ I|⌘ \\+ I| - |Underlined text|Ctrl \\+ U|⌘ \\+ U| - |Strikethrough text|Ctrl \\+ Shift \\+ S|⌘ \\+ Shift \\+ S| - -- Markup mode - - - - |Formatting|Markup|Result| - |:---|:---|:---| - |Bold text|\`**Bold**\`|**Bold**| - |Italic|\`*Italic*\`|*Italic*| - |Underlined text|\`++Underlined++\`|++Underlined++| - |Strikethrough text|\`~~Strikethrough~~\`|~~Strikethrough~~| - -{% endlist %} - -#### Context menu - -Select this text and you will see **a context menu**. - -#### Auto-conversion - -Quickly create blocks by entering characters that will be replaced by blocks. For example, the automatic conversion of \`-\` and space creates a list, \`>\` and space creates a quote. Try it out. - ---- - -### Current and future features - -[X] Some already finished things - -[ ] VS Code plugin - -[ ] Mobile version - -### And a multitude of other functionalities :sweat_smile: :fire: - -See - -# More examples {#anchor} - -{% cut "Headings" %} - -# Heading 1 - -## Heading 2 - -### Heading 3 - -#### Heading 4 - -##### Heading 5 - -###### Heading 6 - -{% endcut %} - -{% cut "This is a cut heading" %} - -**A** *here* ~~it~~ ++is awesome++ ^c^~o~^n^~t~^e^~n~^t^ - -> Done deal - deal done \`(quote)\` - -{% endcut %} - -{% cut "Formulas" %} - -This is an inline formula: $\\sqrt{3x-1}+(1+x)^2$ - -And here is a block formula: - -$$f(\\relax{x}) = \\int_{-\\infty}^\\infty - \\hat f(\\xi)\\,e^{2 \\pi i \\xi x} - \\,d\\xi -$$ - -*Click on the formula to edit it* - -{% endcut %} - ---- - -#| -|| - -Category - -| - -Subcategory - -| - -Description - -|| -|| - -**Technology** - -| - -==Programming== - -| - -***Innovative coding techniques and tools*** - -|| -|| - -**Science** - -| - -==Physics== - -| - -Understanding the laws of the universe and fundamental forces - -|| -|| - -**Literature** - -| - -==Fiction== - -| - -Exploring imaginary worlds and character-driven stories - -|| -|| - -**Education** - -| - -==E-learning== - -| - -!!!!!!!!!!!!!!!!!!!!!!!! - -New approaches to learning in the digital age - -|| -|| - -**Health** - -| - -==Nutrition== - -| - -~~++Balanced diets++~~ for a healthy lifestyle - -|| -|# - ---- - -{% note info "Attention, please!" %} - -* Thank - - 1. you - - 2. for - - 1. your - - 3. attention - -* (nested lists) - -> > > Quotes -> > -> > Nested -> -> As well - -And ##monospace## can be **##com##**##bined\\*## - -{% endnote %} - ---- +3. pp +4. hh `.trim(); diff --git a/src/extensions/markdown/Lists/commands.ts b/src/extensions/markdown/Lists/commands.ts index c79d8ea78..fe1225ba5 100644 --- a/src/extensions/markdown/Lists/commands.ts +++ b/src/extensions/markdown/Lists/commands.ts @@ -1,10 +1,7 @@ -import type {NodeType} from 'prosemirror-model'; -import { - sinkListItem as defaultSinkListItem, - liftListItem, - wrapInList, -} from 'prosemirror-schema-list'; -import type {Command, EditorState, Transaction} from 'prosemirror-state'; +import {Fragment, type NodeRange, type NodeType, Slice} from 'prosemirror-model'; +import {wrapInList} from 'prosemirror-schema-list'; +import type {Command, Transaction} from 'prosemirror-state'; +import {ReplaceAroundStep, liftTarget} from 'prosemirror-transform'; import {joinPreviousBlock} from '../../../commands/join'; @@ -28,27 +25,89 @@ export const joinPrevList = joinPreviousBlock({ skipNode: isListOrItemNode, }); +const sink = (tr: Transaction, range: NodeRange, itemType: NodeType) => { + const before = tr.mapping.map(range.start); + const after = tr.mapping.map(range.end); + const startIndex = tr.mapping.map(range.startIndex); + + const parent = range.parent, + nodeBefore = parent.child(startIndex - 1); + + const nestedBefore = nodeBefore.lastChild && nodeBefore.lastChild.type === parent.type; + const inner = Fragment.from(nestedBefore ? itemType.create() : null); + const slice = new Slice( + Fragment.from(itemType.create(null, Fragment.from(parent.type.create(null, inner)))), + nestedBefore ? 3 : 1, + 0, + ); + + tr.step( + new ReplaceAroundStep( + before - (nestedBefore ? 3 : 1), + after, + before, + after, + slice, + 1, + true, + ), + ); + return true; +}; + export function sinkOnlySelectedListItem(itemType: NodeType): Command { - const sink = defaultSinkListItem(itemType); - const lift = liftListItem(itemType); - - return (state: EditorState, dispatch?: (tr: Transaction) => void): boolean => { - let tr: Transaction = state.tr; - const sinkResult = sink(state, (transaction) => { - tr = transaction; - }); - if (!sinkResult) { - return false; - } - // TODO lift for nested bullet или order - if (!tr) { - lift(state); - } + return function (state, dispatch) { + const {$from, $to} = state.selection; + const range = $from.blockRange( + $to, + (node) => node.childCount > 0 && node.firstChild!.type === itemType, + ); + if (!range) return false; + const startIndex = range.startIndex; + if (startIndex === 0) return false; + const parent = range.parent, + nodeBefore = parent.child(startIndex - 1); + if (nodeBefore.type !== itemType) return false; if (dispatch) { - dispatch(tr); - } + const {tr} = state; + const before = range.start; + const after = range.end; + + let i = after - 1; + while (i > before) { + const selectionPos = tr.mapping.map($to.pos); + const $endPos = tr.doc.resolve(i); + const startPos = $endPos.start($endPos.depth); + const $startPos = tr.doc.resolve(startPos); + const blockRange = $startPos.blockRange($endPos); + if (blockRange?.start) { + const $blockRangeStart = tr.doc.resolve(blockRange?.start); + + const shouldLift = + blockRange.start > tr.mapping.map(selectionPos) && + isListNode($blockRangeStart.parent); + + if (shouldLift) { + i = blockRange.start; + const target = liftTarget(blockRange); + + if (target === null) { + break; + } + + tr.lift(blockRange, target); + } + } + i--; + } + + sink(tr, range, itemType); + + dispatch(tr.scrollIntoView()); + return true; + } return true; }; } diff --git a/src/extensions/markdown/Lists/index.ts b/src/extensions/markdown/Lists/index.ts index e6f0b9afe..1ae82e062 100644 --- a/src/extensions/markdown/Lists/index.ts +++ b/src/extensions/markdown/Lists/index.ts @@ -8,7 +8,6 @@ import {actions} from './actions'; import {joinPrevList, sinkOnlySelectedListItem, toList} from './commands'; import {ListAction} from './const'; import {ListsInputRulesExtension, type ListsInputRulesOptions} from './inputrules'; -import {collapseListsPlugin} from './plugins/CollapseListsPlugin'; import {mergeListsPlugin} from './plugins/MergeListsPlugin'; export {ListNode, ListsAttr, blType, liType, olType} from './ListsSpecs'; @@ -51,7 +50,8 @@ export const Lists: ExtensionAuto = (builder, opts) => { builder.addPlugin(mergeListsPlugin); - builder.addPlugin(collapseListsPlugin); + // FIXME: @makhnatkin enable after debug + // builder.addPlugin(collapseListsPlugin); builder .addAction(ListAction.ToBulletList, actions.toBulletList) From 89ba94ce1b6543dbfe7b0271725d948bb74ba076 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Mon, 24 Mar 2025 18:46:01 +0100 Subject: [PATCH 3/5] feat(Lists): added sinkOnlySelectedListItem tests --- package-lock.json | 7 + package.json | 1 + .../markdown/Lists/commands.test.ts | 381 +++++++++--------- src/extensions/markdown/Lists/commands.ts | 79 ++-- src/extensions/markdown/Lists/index.ts | 5 +- src/extensions/markdown/Lists/ist.js | 100 ----- 6 files changed, 251 insertions(+), 322 deletions(-) delete mode 100644 src/extensions/markdown/Lists/ist.js diff --git a/package-lock.json b/package-lock.json index 1f40efe55..be50ee365 100644 --- a/package-lock.json +++ b/package-lock.json @@ -102,6 +102,7 @@ "gulp-sass": "6.0.0", "gulp-sourcemaps": "3.0.0", "identity-obj-proxy": "^3.0.0", + "ist": "1.1.7", "jest": "^29.7.0", "jest-css-modules": "^2.1.0", "jest-environment-jsdom": "^29.7.0", @@ -14836,6 +14837,12 @@ "node": ">=0.10.0" } }, + "node_modules/ist": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/ist/-/ist-1.1.7.tgz", + "integrity": "sha512-ex9JyqY+tCjBlxN1pXlqxEgtGGUGp1TG83ll1xpu8SfPgOhfAhEGCuepNHlB+d7Le+hLoBcfCu/G0ZQaFbi9hA==", + "dev": true + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", diff --git a/package.json b/package.json index 0689acd0c..f1dfc0295 100644 --- a/package.json +++ b/package.json @@ -265,6 +265,7 @@ "gulp-sass": "6.0.0", "gulp-sourcemaps": "3.0.0", "identity-obj-proxy": "^3.0.0", + "ist": "1.1.7", "jest": "^29.7.0", "jest-css-modules": "^2.1.0", "jest-environment-jsdom": "^29.7.0", diff --git a/src/extensions/markdown/Lists/commands.test.ts b/src/extensions/markdown/Lists/commands.test.ts index ec00ed71c..ef04bb36f 100644 --- a/src/extensions/markdown/Lists/commands.test.ts +++ b/src/extensions/markdown/Lists/commands.test.ts @@ -1,5 +1,6 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import ist from 'ist'; import type {Node} from 'prosemirror-model'; -import {liftListItem, splitListItem, wrapInList} from 'prosemirror-schema-list'; import { type Command, EditorState, @@ -7,10 +8,9 @@ import { Selection, TextSelection, } from 'prosemirror-state'; -import {blockquote, doc, eq, li, ol, p, schema, ul} from 'prosemirror-test-builder'; +import {doc, eq, li, p, schema, ul} from 'prosemirror-test-builder'; import {sinkOnlySelectedListItem} from 'src/extensions/markdown/Lists/commands'; -import ist from 'src/extensions/markdown/Lists/ist'; function selFor(doc: Node) { const a = (doc as any).tag.a, @@ -18,7 +18,7 @@ function selFor(doc: Node) { if (a !== null) { const $a = doc.resolve(a); if ($a.parent.inlineContent) - return new TextSelection($a, b != null ? doc.resolve(b) : undefined); + return new TextSelection($a, b !== null ? doc.resolve(b) : undefined); else return new NodeSelection($a); } return Selection.atStart(doc); @@ -26,197 +26,210 @@ function selFor(doc: Node) { function apply(doc: Node, command: Command, result: Node | null) { let state = EditorState.create({doc, selection: selFor(doc)}); + // eslint-disable-next-line no-return-assign command(state, (tr) => (state = state.apply(tr))); ist(state.doc, result || doc, eq); + // eslint-disable-next-line no-eq-null if (result && (result as any).tag.a != null) ist(state.selection, selFor(result), eq); } -describe('wrapInList', () => { - const wrap = wrapInList(schema.nodes.bullet_list); - const wrapo = wrapInList(schema.nodes.ordered_list); - - it('can wrap a paragraph', () => apply(doc(p('foo')), wrap, doc(ul(li(p('foo')))))); - - it('can wrap a nested paragraph', () => - apply(doc(blockquote(p('foo'))), wrapo, doc(blockquote(ol(li(p('foo'))))))); - - it('can wrap multiple paragraphs', () => - apply( - doc(p('foo'), p('bar'), p('baz')), - wrap, - doc(p('foo'), ul(li(p('bar')), li(p('baz')))), - )); - - it("doesn't wrap the first paragraph in a list item", () => - apply(doc(ul(li(p('foo')))), wrap, null)); - - it("doesn't wrap the first para in a different type of list item", () => - apply(doc(ol(li(p('foo')))), wrapo, null)); - - it('does wrap the second paragraph in a list item', () => - apply(doc(ul(li(p('foo'), p('bar')))), wrap, doc(ul(li(p('foo'), ul(li(p('bar')))))))); - - it('joins with the list item above when wrapping its first paragraph', () => - apply( - doc(ul(li(p('foo')), li(p('bar')), li(p('baz')))), - wrapo, - doc(ul(li(p('foo'), ol(li(p('bar')))), li(p('baz')))), - )); - - it('only splits items where valid', () => - apply( - doc(p('one'), ol(li('two')), p('three')), - wrapo, - doc(ol(li(p('one'), ol(li('two'))), li(p('three')))), - )); -}); - -describe('splitListItem', () => { - const split = splitListItem(schema.nodes.list_item); - - it('has no effect outside of a list', () => apply(doc(p('foobar')), split, null)); - - it('has no effect on the top level', () => apply(doc('', p('foobar')), split, null)); - - it('can split a list item', () => - apply(doc(ul(li(p('foobar')))), split, doc(ul(li(p('foo')), li(p('bar')))))); - - it('can split a list item at the end', () => - apply(doc(ul(li(p('foobar')))), split, doc(ul(li(p('foobar')), li(p()))))); - - it('deletes selected content', () => - apply(doc(ul(li(p('foobar')))), split, doc(ul(li(p('foo')), li(p('r')))))); - - it('splits when lifting from a nested list', () => - apply( - doc(ul(li(p('a'), ul(li(p('b')), li(p(''))))), p('x')), - split, - doc(ul(li(p('a'), ul(li(p('b')))), li(p(''))), p('x')), - )); - - it('can lift from a continued nested list item', () => - apply( - doc(ul(li(p('a'), ul(li(p('b')), li(p('ok'), p(''))))), p('x')), - split, - doc(ul(li(p('a'), ul(li(p('b')), li(p('ok')))), li(p(''))), p('x')), - )); - - it('correctly lifts an entirely empty sublist', () => - apply( - doc(ul(li(p('one'), ul(li(p(''))), p('two')))), - split, - doc(ul(li(p('one')), li(p('')), li(p('two')))), - )); -}); - -describe('liftListItem', () => { - const lift = liftListItem(schema.nodes.list_item); - - it('can lift from a nested list', () => - apply( - doc(ul(li(p('hello'), ul(li(p('one')), li(p('two')))))), - lift, - doc(ul(li(p('hello')), li(p('one'), ul(li(p('two')))))), - )); - - it('can lift two items from a nested list', () => - apply( - doc(ul(li(p('hello'), ul(li(p('one')), li(p('two')))))), - lift, - doc(ul(li(p('hello')), li(p('one')), li(p('two')))), - )); - - it('can lift two items from a nested three-item list', () => - apply( - doc(ul(li(p('hello'), ul(li(p('one')), li(p('two')), li(p('three')))))), - lift, - doc(ul(li(p('hello')), li(p('one')), li(p('two'), ul(li(p('three')))))), - )); - - it('can lift an item out of a list', () => - apply(doc(p('a'), ul(li(p('b'))), p('c')), lift, doc(p('a'), p('b'), p('c')))); - - it('can lift two items out of a list', () => - apply( - doc(p('a'), ul(li(p('b')), li(p('c'))), p('d')), - lift, - doc(p('a'), p('b'), p('c'), p('d')), - )); - - it('can lift three items from the middle of a list', () => - apply( - doc(ul(li(p('a')), li(p('b')), li(p('c')), li(p('d')), li(p('e')))), - lift, - doc(ul(li(p('a'))), p('b'), p('c'), p('d'), ul(li(p('e')))), - )); - - it('can lift the first item from a list', () => - apply( - doc(ul(li(p('a')), li(p('b')), li(p('c')))), - lift, - doc(p('a'), ul(li(p('b')), li(p('c')))), - )); - - it('can lift the last item from a list', () => - apply( - doc(ul(li(p('a')), li(p('b')), li(p('c')))), - lift, - doc(ul(li(p('a')), li(p('b'))), p('c')), - )); - - it('joins adjacent lists when lifting an item with subitems', () => - apply( - doc(ol(li(p('a'), ol(li(p('b'), ol(li(p('c')))), li(p('d')))), li(p('e')))), - lift, - doc(ol(li(p('a')), li(p('b'), ol(li(p('c')), li(p('d')))), li(p('e')))), - )); +describe('sinkOnlySelectedListItem', () => { + const sink = sinkOnlySelectedListItem(schema.nodes.list_item); - it('only joins adjacent lists when lifting if their types match', () => + it('can wrap a simple item in a list', () => apply( - doc(ol(li(p('a'), ul(li(p('b'), ol(li(p('c')))), li(p('d')))))), - lift, - doc(ol(li(p('a')), li(p('b'), ol(li(p('c'))), ul(li(p('d')))))), + doc(ul(li(p('one')), li(p('two')), li(p('three')))), + sink, + doc(ul(li(p('one'), ul(li(p('two')))), li(p('three')))), )); -}); - -describe('sinkListItem', () => { - const sink = sinkOnlySelectedListItem(schema.nodes.list_item); - // it('can wrap a simple item in a list', () => - // apply( - // doc(ul(li(p('one')), li(p('two')), li(p('three')))), - // sink, - // doc(ul(li(p('one'), ul(li(p('two')))), li(p('three')))), - // )); - // - // it("won't wrap the first item in a sublist", () => - // apply(doc(ul(li(p('one')), li(p('two')), li(p('three')))), sink, null)); - // - // it("will move an item's content into the item above", () => - // apply( - // doc(ul(li(p('one')), li(p('...'), ul(li(p('two')))), li(p('three')))), - // sink, - // doc(ul(li(p('one')), li(p('...'), ul(li(p('two')), li(p('three')))))), - // )); - // - // it('sel 1, can wrap a simple item in a list', () => - // apply( - // doc(ul(li(p('one')), li(p('two')), li(p('three')))), - // sink, - // doc(ul(li(p('one'), ul(li(p('two')), li(p('three')))))), - // )); - // - // it('move selected', () => - // apply( - // doc(ul(li(p('one')), li(p('two'), ul(li(p('three')))))), - // sink, - // doc(ul(li(p('one'), ul(li(p('two'), ul(li(p('three')))))))), - // )); + it("won't wrap the first item in a sublist", () => + apply(doc(ul(li(p('one')), li(p('two')), li(p('three')))), sink, null)); - it('move only selected', () => + it("will move an item's content into the item above", () => apply( - doc(ul(li(p('one')), li(p('two'), ul(li(p('three')))))), + doc(ul(li(p('one')), li(p('...'), ul(li(p('two')))), li(p('three')))), + sink, + doc(ul(li(p('one')), li(p('...'), ul(li(p('two')), li(p('three')))))), + )); + + it('transforms a complex nested list with selection markers', () => + apply( + doc( + ul( + li(p('aa')), + li( + p('bb'), + ul( + li(p('cc')), + li(p('dd'), ul(li(p('ee')), li(p('ss')))), + li(p('zz')), + li(p('ww')), + ), + ), + li(p('pp')), + li(p('hh')), + ), + ), + sink, + doc( + ul( + li( + p('aa'), + ul( + li(p('bb'), ul(li(p('cc')))), + li(p('dd'), ul(li(p('ee')), li(p('ss')))), + li(p('zz')), + li(p('ww')), + ), + ), + li(p('pp')), + li(p('hh')), + ), + ), + )); + + it('sinks a top-level list item with double selection markers into the previous item', () => + apply( + doc( + ul( + li(p('aa')), + li( + p('bb'), + ul( + li(p('cc')), + li(p('dd'), ul(li(p('ee')), li(p('ss')))), + li(p('zz')), + li(p('ww')), + ), + ), + li(p('pp')), + li(p('hh')), + ), + ), + sink, + doc( + ul( + li( + p('aa'), + ul( + li(p('bb')), + li(p('cc')), + li(p('dd'), ul(li(p('ee')), li(p('ss')))), + li(p('zz')), + li(p('ww')), + ), + ), + li(p('pp')), + li(p('hh')), + ), + ), + )); + + it('sinks nested list items into a deeper hierarchy when selection spans multiple items', () => + apply( + doc( + ul( + li(p('aa')), + li( + p('bb'), + ul( + li(p('cc')), + li(p('dd'), ul(li(p('ee')), li(p('ss')))), + li(p('zz')), + li(p('ww')), + ), + ), + li(p('pp')), + li(p('hh')), + ), + ), + sink, + doc( + ul( + li(p('aa')), + li( + p('bb'), + ul( + li(p('cc'), ul(li(p('dd'), ul(li(p('ee')), li(p('ss')))), li(p('zz')))), + li(p('ww')), + ), + ), + li(p('pp')), + li(p('hh')), + ), + ), + )); + + // expected result should be the same as + // sinks nested list items into a deeper hierarchy when selection spans multiple items + it('sinks nested list items with an upward staircase selection', () => + apply( + doc( + ul( + li(p('aa')), + li( + p('bb'), + ul( + li(p('cc')), + li(p('dd'), ul(li(p('ee')), li(p('ss')))), + li(p('zz')), + li(p('ww')), + ), + ), + li(p('pp')), + li(p('hh')), + ), + ), + sink, + doc( + ul( + li(p('aa')), + li( + p('bb'), + ul( + li(p('cc'), ul(li(p('dd'), ul(li(p('ee')), li(p('ss')))), li(p('zz')))), + li(p('ww')), + ), + ), + li(p('pp')), + li(p('hh')), + ), + ), + )); + + it('sinks a top-level list item with mixed selection markers from both levels', () => + apply( + doc( + ul( + li(p('aa')), + li( + p('bb'), + ul( + li(p('cc')), + li(p('dd'), ul(li(p('ee')), li(p('ss')))), + li(p('zz')), + li(p('ww')), + ), + ), + li(p('pp')), + li(p('hh')), + ), + ), sink, - doc(ul(li(p('one'), ul(li(p('two')), li(p('three')))))), + doc( + ul( + li( + p('aa'), + ul( + li(p('bb'), ul(li(p('cc')), li(p('dd'), ul(li(p('ee')), li(p('ss')))))), + li(p('zz')), + li(p('ww')), + ), + ), + li(p('pp')), + li(p('hh')), + ), + ), )); }); diff --git a/src/extensions/markdown/Lists/commands.ts b/src/extensions/markdown/Lists/commands.ts index fe1225ba5..84eb7da97 100644 --- a/src/extensions/markdown/Lists/commands.ts +++ b/src/extensions/markdown/Lists/commands.ts @@ -25,13 +25,17 @@ export const joinPrevList = joinPreviousBlock({ skipNode: isListOrItemNode, }); +/* + Simplified `sinkListItem` from `prosemirror-schema-list` without `state`/`dispatch`, + sinks list items deeper. + */ const sink = (tr: Transaction, range: NodeRange, itemType: NodeType) => { const before = tr.mapping.map(range.start); const after = tr.mapping.map(range.end); const startIndex = tr.mapping.map(range.startIndex); - const parent = range.parent, - nodeBefore = parent.child(startIndex - 1); + const parent = range.parent; + const nodeBefore = parent.child(startIndex - 1); const nestedBefore = nodeBefore.lastChild && nodeBefore.lastChild.type === parent.type; const inner = Fragment.from(nestedBefore ? itemType.create() : null); @@ -56,58 +60,61 @@ const sink = (tr: Transaction, range: NodeRange, itemType: NodeType) => { }; export function sinkOnlySelectedListItem(itemType: NodeType): Command { - return function (state, dispatch) { - const {$from, $to} = state.selection; - const range = $from.blockRange( + return ({tr, selection}, dispatch) => { + const {$from, $to} = selection; + const selectionRange = $from.blockRange( $to, (node) => node.childCount > 0 && node.firstChild!.type === itemType, ); - if (!range) return false; - const startIndex = range.startIndex; - if (startIndex === 0) return false; - const parent = range.parent, - nodeBefore = parent.child(startIndex - 1); - if (nodeBefore.type !== itemType) return false; - - if (dispatch) { - const {tr} = state; - const before = range.start; - const after = range.end; + if (!selectionRange) { + return false; + } - let i = after - 1; - while (i > before) { - const selectionPos = tr.mapping.map($to.pos); - const $endPos = tr.doc.resolve(i); - const startPos = $endPos.start($endPos.depth); - const $startPos = tr.doc.resolve(startPos); - const blockRange = $startPos.blockRange($endPos); + const {startIndex, parent, start, end} = selectionRange; + if (startIndex === 0) { + return false; + } - if (blockRange?.start) { - const $blockRangeStart = tr.doc.resolve(blockRange?.start); + const nodeBefore = parent.child(startIndex - 1); + if (nodeBefore.type !== itemType) { + return false; + } + if (dispatch) { + // lifts following list items sequentially to prepare correct nesting structure + let currentEnd = end - 1; + while (currentEnd > start) { + const selectionEnd = tr.mapping.map($to.pos); + + const $candidateBlockEnd = tr.doc.resolve(currentEnd); + const candidateBlockStartPos = $candidateBlockEnd.before($candidateBlockEnd.depth); + const $candidateBlockStart = tr.doc.resolve(candidateBlockStartPos); + const candidateBlockRange = $candidateBlockStart.blockRange($candidateBlockEnd); + + if (candidateBlockRange?.start) { + const $rangeStart = tr.doc.resolve(candidateBlockRange.start); const shouldLift = - blockRange.start > tr.mapping.map(selectionPos) && - isListNode($blockRangeStart.parent); + candidateBlockRange.start > selectionEnd && isListNode($rangeStart.parent); if (shouldLift) { - i = blockRange.start; - const target = liftTarget(blockRange); + currentEnd = candidateBlockRange.start; - if (target === null) { - break; + const targetDepth = liftTarget(candidateBlockRange); + if (targetDepth !== null) { + tr.lift(candidateBlockRange, targetDepth); } - - tr.lift(blockRange, target); } } - i--; + + currentEnd--; } - sink(tr, range, itemType); + // sinks the selected list item deeper into the list hierarchy + sink(tr, selectionRange, itemType); dispatch(tr.scrollIntoView()); return true; } - return true; + return false; }; } diff --git a/src/extensions/markdown/Lists/index.ts b/src/extensions/markdown/Lists/index.ts index 1ae82e062..127000335 100644 --- a/src/extensions/markdown/Lists/index.ts +++ b/src/extensions/markdown/Lists/index.ts @@ -1,5 +1,7 @@ import {liftListItem, splitListItem} from 'prosemirror-schema-list'; +import {collapseListsPlugin} from 'src/extensions/markdown/Lists/plugins/CollapseListsPlugin'; + import type {Action, ExtensionAuto, Keymap} from '../../../core'; import {withLogAction} from '../../../utils/keymap'; @@ -50,8 +52,7 @@ export const Lists: ExtensionAuto = (builder, opts) => { builder.addPlugin(mergeListsPlugin); - // FIXME: @makhnatkin enable after debug - // builder.addPlugin(collapseListsPlugin); + builder.addPlugin(collapseListsPlugin); builder .addAction(ListAction.ToBulletList, actions.toBulletList) diff --git a/src/extensions/markdown/Lists/ist.js b/src/extensions/markdown/Lists/ist.js deleted file mode 100644 index 64088396d..000000000 --- a/src/extensions/markdown/Lists/ist.js +++ /dev/null @@ -1,100 +0,0 @@ -var opNames = [ - '==', - function (a, b) { - return a == b; - }, - '!=', - function (a, b) { - return a != b; - }, - '===', - function (a, b) { - return a === b; - }, - '!==', - function (a, b) { - return a !== b; - }, - '<', - function (a, b) { - return a < b; - }, - '>=', - function (a, b) { - return a >= b; - }, - '>', - function (a, b) { - return a > b; - }, - '<=', - function (a, b) { - return a <= b; - }, -]; -var ops = {}; -for (var i = 0; i < opNames.length; i += 2) ops[opNames[i]] = opNames[i + 1]; - -function message(a, b, compare) { - if (!compare || typeof compare == 'string') { - var index = opNames.indexOf(compare || '=='); - return a + ' ' + opNames[index + (index % 4 ? -2 : 2)] + ' ' + b; - } - return '!' + compare.name + '(' + a + ', ' + b + ')'; -} - -function ist(a, b, compare) { - if (arguments.length == 1) { - if (!a) throw new ist.Failure('!' + a, 'ist'); - } else { - var cmpFn = compare; - if (typeof compare == 'string') { - if (!(cmpFn = ops[compare])) throw new RangeError('Unknow operator ' + compare); - } else if (!compare) { - cmpFn = ops['==']; - } - if (!cmpFn(a, b)) { - var cmpName = (typeof compare == 'string' ? compare : compare && compare.name) || '=='; - throw new ist.Failure(message(a, b, compare), 'ist'); - } - } -} - -ist.Failure = function (message, src) { - this.message = message; - if (Error.captureStackTrace) Error.captureStackTrace(this); - else this.stack = new Error(message).stack; - if (this.stack) { - var lines = this.stack.split('\n'), - re = new RegExp('\\b' + src + '\\b'); - for (var i = 0; i < lines.length - 1; i++) { - if (re.test(lines[i]) && !/\bFailure\b/.test(lines[i])) { - this.message += ' ' + lines[i + 1].trim(); - break; - } - } - } -}; -ist.Failure.prototype = Object.create(Error.prototype); - -ist.throws = function throws(f, expected) { - let threw = true; - try { - f(); - threw = false; - } catch (e) { - var matches = !expected - ? true - : expected.test - ? expected.test(e.message) - : typeof expected == 'string' - ? e.message == expected - : expected(e); - if (!matches) throw e; - } - if (!threw) throw new ist.Failure('Did not throw', 'throws'); -}; - -ist.default = ist; - -export default ist; From f6f3f854f6ba8916ce2abb1a75b745e1c7522e03 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Mon, 24 Mar 2025 19:33:12 +0100 Subject: [PATCH 4/5] feat(Lists): reverted content --- demo/defaults/content.ts | 557 +++++++++++++++++++-------------------- 1 file changed, 267 insertions(+), 290 deletions(-) diff --git a/demo/defaults/content.ts b/demo/defaults/content.ts index 54fee1d3e..b16fb0b73 100644 --- a/demo/defaults/content.ts +++ b/demo/defaults/content.ts @@ -1,301 +1,278 @@ -// export const markup = ` -//   -// -// Welcome to the editor! Start typing the character \`/\` -// -// ![Markdown Editor](https://github.com/user-attachments/assets/0b4e5f65-54cf-475f-9c68-557a4e9edb46 =700x) -// -// ## Markdown WYSIWYG and markup editor -// -// MarkdownEditor is a powerful tool for working with Markdown, which combines WYSIWYG and Markup modes. This means that you can create and edit content in a convenient visual mode, as well as have full control over the markup. -// -// The editor supports following formats: -// -// * WYSIWYG -// -// * markup -// -// Click on the gear in the upper right corner to change the mode and see the \`md\` markup. -// -// ### Various blocks included -// -// {% cut "Combine different blocks" %} -// -// {% note info "Block for notes, tips, warnings, and alerts" %} -// -// Depending on the content, notes with different titles and formats are used: -// -// * Note: provides additional information. -// * Tip: offers a recommendation. -// * Warning: issues a warning. -// * Alert: indicates a restriction. -// -// {% endnote %} -// -// > [Improve](https://github.com/gravity-ui/markdown-editor/blob/main/docs/how-to-add-preview.md) the editor interface -// > -// > *improved by you* -// -// {% endcut %} -// -// Or write your extension using a [convenient api](https://github.com/gravity-ui/markdown-editor/blob/main/docs/how-to-create-extension.md) -// -// ### A user-friendly API is provided -// -// Easily connect to your React app with a hook: -// -// \`\`\`plaintext -// import React from 'react'; -// import { useMarkdownEditor, MarkdownEditorView } from '@gravity-ui/markdown-editor'; -// import { toaster } from '@gravity-ui/uikit/toaster-singleton'; -// -// function Editor({ onSubmit }) { -// const editor = useMarkdownEditor({ allowHTML: false }); -// -// React.useEffect(() => { -// function submitHandler() { -// // Serialize current content to markdown markup -// const value = editor.getValue(); -// onSubmit(value); -// } -// -// editor.on('submit', submitHandler); -// return () => { -// editor.off('submit', submitHandler); -// }; -// }, [onSubmit]); -// -// return ; -// } -// \`\`\` -// -// ### Convenient UX control is equipped -// -// #### Hot keys -// -// {% list tabs %} -// -// - WYSIWYG mode -// -// -// -// |Formatting|Windows Shortcut|Mac OS Shortcut| -// |:---|:---|:---| -// |Bold text|Ctrl \\+ B|⌘ \\+ B| -// |Italic|Ctrl \\+ I|⌘ \\+ I| -// |Underlined text|Ctrl \\+ U|⌘ \\+ U| -// |Strikethrough text|Ctrl \\+ Shift \\+ S|⌘ \\+ Shift \\+ S| -// -// - Markup mode -// -// -// -// |Formatting|Markup|Result| -// |:---|:---|:---| -// |Bold text|\`**Bold**\`|**Bold**| -// |Italic|\`*Italic*\`|*Italic*| -// |Underlined text|\`++Underlined++\`|++Underlined++| -// |Strikethrough text|\`~~Strikethrough~~\`|~~Strikethrough~~| -// -// {% endlist %} -// -// #### Context menu -// -// Select this text and you will see **a context menu**. -// -// #### Auto-conversion -// -// Quickly create blocks by entering characters that will be replaced by blocks. For example, the automatic conversion of \`-\` and space creates a list, \`>\` and space creates a quote. Try it out. -// -// --- -// -// ### Current and future features -// -// [X] Some already finished things -// -// [ ] VS Code plugin -// -// [ ] Mobile version -// -// ### And a multitude of other functionalities :sweat_smile: :fire: -// -// See -// -// # More examples {#anchor} -// -// {% cut "Headings" %} -// -// # Heading 1 -// -// ## Heading 2 -// -// ### Heading 3 -// -// #### Heading 4 -// -// ##### Heading 5 -// -// ###### Heading 6 -// -// {% endcut %} -// -// {% cut "This is a cut heading" %} -// -// **A** *here* ~~it~~ ++is awesome++ ^c^~o~^n^~t~^e^~n~^t^ -// -// > Done deal - deal done \`(quote)\` -// -// {% endcut %} -// -// {% cut "Formulas" %} -// -// This is an inline formula: $\\sqrt{3x-1}+(1+x)^2$ -// -// And here is a block formula: -// -// $$f(\\relax{x}) = \\int_{-\\infty}^\\infty -// \\hat f(\\xi)\\,e^{2 \\pi i \\xi x} -// \\,d\\xi -// $$ -// -// *Click on the formula to edit it* -// -// {% endcut %} -// -// --- -// -// #| -// || -// -// Category -// -// | -// -// Subcategory -// -// | -// -// Description -// -// || -// || -// -// **Technology** -// -// | -// -// ==Programming== -// -// | -// -// ***Innovative coding techniques and tools*** -// -// || -// || -// -// **Science** -// -// | -// -// ==Physics== -// -// | -// -// Understanding the laws of the universe and fundamental forces -// -// || -// || -// -// **Literature** -// -// | -// -// ==Fiction== -// -// | -// -// Exploring imaginary worlds and character-driven stories -// -// || -// || -// -// **Education** -// -// | -// -// ==E-learning== -// -// | -// -// !!!!!!!!!!!!!!!!!!!!!!!! -// -// New approaches to learning in the digital age -// -// || -// || -// -// **Health** -// -// | -// -// ==Nutrition== -// -// | -// -// ~~++Balanced diets++~~ for a healthy lifestyle -// -// || -// |# -// -// --- -// -// {% note info "Attention, please!" %} -// -// * Thank -// -// 1. you -// -// 2. for -// -// 1. your -// -// 3. attention -// -// * (nested lists) -// -// > > > Quotes -// > > -// > > Nested -// > -// > As well -// -// And ##monospace## can be **##com##**##bined\\*## -// -// {% endnote %} -// -// --- -// -// `.trim(); - -// FIXME: @makhnatkin return after debug export const markup = ` -1. aa +  -2. bb +Welcome to the editor! Start typing the character \`/\` - 1. cc +![Markdown Editor](https://github.com/user-attachments/assets/0b4e5f65-54cf-475f-9c68-557a4e9edb46 =700x) - 2. dd +## Markdown WYSIWYG and markup editor - 1. ee +MarkdownEditor is a powerful tool for working with Markdown, which combines WYSIWYG and Markup modes. This means that you can create and edit content in a convenient visual mode, as well as have full control over the markup. - 2. ss +The editor supports following formats: - 3. zz +* WYSIWYG - 4. ww +* markup -3. pp +Click on the gear in the upper right corner to change the mode and see the \`md\` markup. + +### Various blocks included + +{% cut "Combine different blocks" %} + +{% note info "Block for notes, tips, warnings, and alerts" %} + +Depending on the content, notes with different titles and formats are used: + +* Note: provides additional information. +* Tip: offers a recommendation. +* Warning: issues a warning. +* Alert: indicates a restriction. + +{% endnote %} + +> [Improve](https://github.com/gravity-ui/markdown-editor/blob/main/docs/how-to-add-preview.md) the editor interface +> +> *improved by you* + +{% endcut %} + +Or write your extension using a [convenient api](https://github.com/gravity-ui/markdown-editor/blob/main/docs/how-to-create-extension.md) + +### A user-friendly API is provided + +Easily connect to your React app with a hook: + +\`\`\`plaintext +import React from 'react'; +import { useMarkdownEditor, MarkdownEditorView } from '@gravity-ui/markdown-editor'; +import { toaster } from '@gravity-ui/uikit/toaster-singleton'; + +function Editor({ onSubmit }) { + const editor = useMarkdownEditor({ allowHTML: false }); + + React.useEffect(() => { + function submitHandler() { + // Serialize current content to markdown markup + const value = editor.getValue(); + onSubmit(value); + } + + editor.on('submit', submitHandler); + return () => { + editor.off('submit', submitHandler); + }; + }, [onSubmit]); + + return ; +} +\`\`\` + +### Convenient UX control is equipped + +#### Hot keys + +{% list tabs %} + +- WYSIWYG mode + + + + |Formatting|Windows Shortcut|Mac OS Shortcut| + |:---|:---|:---| + |Bold text|Ctrl \\+ B|⌘ \\+ B| + |Italic|Ctrl \\+ I|⌘ \\+ I| + |Underlined text|Ctrl \\+ U|⌘ \\+ U| + |Strikethrough text|Ctrl \\+ Shift \\+ S|⌘ \\+ Shift \\+ S| + +- Markup mode + + + + |Formatting|Markup|Result| + |:---|:---|:---| + |Bold text|\`**Bold**\`|**Bold**| + |Italic|\`*Italic*\`|*Italic*| + |Underlined text|\`++Underlined++\`|++Underlined++| + |Strikethrough text|\`~~Strikethrough~~\`|~~Strikethrough~~| + +{% endlist %} + +#### Context menu + +Select this text and you will see **a context menu**. + +#### Auto-conversion + +Quickly create blocks by entering characters that will be replaced by blocks. For example, the automatic conversion of \`-\` and space creates a list, \`>\` and space creates a quote. Try it out. + +--- + +### Current and future features + +[X] Some already finished things + +[ ] VS Code plugin + +[ ] Mobile version + +### And a multitude of other functionalities :sweat_smile: :fire: + +See + +# More examples {#anchor} + +{% cut "Headings" %} + +# Heading 1 + +## Heading 2 + +### Heading 3 + +#### Heading 4 + +##### Heading 5 + +###### Heading 6 + +{% endcut %} + +{% cut "This is a cut heading" %} + +**A** *here* ~~it~~ ++is awesome++ ^c^~o~^n^~t~^e^~n~^t^ + +> Done deal - deal done \`(quote)\` + +{% endcut %} + +{% cut "Formulas" %} + +This is an inline formula: $\\sqrt{3x-1}+(1+x)^2$ + +And here is a block formula: + +$$f(\\relax{x}) = \\int_{-\\infty}^\\infty + \\hat f(\\xi)\\,e^{2 \\pi i \\xi x} + \\,d\\xi +$$ + +*Click on the formula to edit it* + +{% endcut %} + +--- + +#| +|| + +Category + +| + +Subcategory + +| + +Description + +|| +|| + +**Technology** + +| + +==Programming== + +| + +***Innovative coding techniques and tools*** + +|| +|| + +**Science** + +| + +==Physics== + +| + +Understanding the laws of the universe and fundamental forces + +|| +|| + +**Literature** + +| + +==Fiction== + +| + +Exploring imaginary worlds and character-driven stories + +|| +|| + +**Education** + +| + +==E-learning== + +| + +!!!!!!!!!!!!!!!!!!!!!!!! + +New approaches to learning in the digital age + +|| +|| + +**Health** + +| + +==Nutrition== + +| + +~~++Balanced diets++~~ for a healthy lifestyle + +|| +|# + +--- + +{% note info "Attention, please!" %} + +* Thank + + 1. you + + 2. for + + 1. your + + 3. attention + +* (nested lists) + +> > > Quotes +> > +> > Nested +> +> As well + +And ##monospace## can be **##com##**##bined\\*## + +{% endnote %} + +--- -4. hh `.trim(); From 5d63d7ed7ea06f05b88f0b95c634b74698fe9715 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Mon, 24 Mar 2025 19:35:26 +0100 Subject: [PATCH 5/5] feat(Lists): reverted collapseListsPlugin import --- src/extensions/markdown/Lists/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/extensions/markdown/Lists/index.ts b/src/extensions/markdown/Lists/index.ts index 127000335..e6f0b9afe 100644 --- a/src/extensions/markdown/Lists/index.ts +++ b/src/extensions/markdown/Lists/index.ts @@ -1,7 +1,5 @@ import {liftListItem, splitListItem} from 'prosemirror-schema-list'; -import {collapseListsPlugin} from 'src/extensions/markdown/Lists/plugins/CollapseListsPlugin'; - import type {Action, ExtensionAuto, Keymap} from '../../../core'; import {withLogAction} from '../../../utils/keymap'; @@ -10,6 +8,7 @@ import {actions} from './actions'; import {joinPrevList, sinkOnlySelectedListItem, toList} from './commands'; import {ListAction} from './const'; import {ListsInputRulesExtension, type ListsInputRulesOptions} from './inputrules'; +import {collapseListsPlugin} from './plugins/CollapseListsPlugin'; import {mergeListsPlugin} from './plugins/MergeListsPlugin'; export {ListNode, ListsAttr, blType, liType, olType} from './ListsSpecs';