diff --git a/package-lock.json b/package-lock.json index 8016e6b0e..7c583fe3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,6 +103,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", @@ -14861,6 +14862,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 1d23c3965..e164e4a9a 100644 --- a/package.json +++ b/package.json @@ -266,6 +266,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/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..ef04bb36f --- /dev/null +++ b/src/extensions/markdown/Lists/commands.test.ts @@ -0,0 +1,235 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import ist from 'ist'; +import type {Node} from 'prosemirror-model'; +import { + type Command, + EditorState, + NodeSelection, + Selection, + TextSelection, +} from 'prosemirror-state'; +import {doc, eq, li, p, schema, ul} from 'prosemirror-test-builder'; + +import {sinkOnlySelectedListItem} from 'src/extensions/markdown/Lists/commands'; + +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)}); + // 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('sinkOnlySelectedListItem', () => { + 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('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('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 df961d7dc..84eb7da97 100644 --- a/src/extensions/markdown/Lists/commands.ts +++ b/src/extensions/markdown/Lists/commands.ts @@ -1,6 +1,7 @@ -import type {NodeType} from 'prosemirror-model'; +import {Fragment, type NodeRange, type NodeType, Slice} from 'prosemirror-model'; import {wrapInList} from 'prosemirror-schema-list'; -import type {Command} from 'prosemirror-state'; +import type {Command, Transaction} from 'prosemirror-state'; +import {ReplaceAroundStep, liftTarget} from 'prosemirror-transform'; import {joinPreviousBlock} from '../../../commands/join'; @@ -23,3 +24,97 @@ export const joinPrevList = joinPreviousBlock({ checkPrevNode: isListNode, 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; + const 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 { + return ({tr, selection}, dispatch) => { + const {$from, $to} = selection; + const selectionRange = $from.blockRange( + $to, + (node) => node.childCount > 0 && node.firstChild!.type === itemType, + ); + if (!selectionRange) { + return false; + } + + const {startIndex, parent, start, end} = selectionRange; + if (startIndex === 0) { + return false; + } + + 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 = + candidateBlockRange.start > selectionEnd && isListNode($rangeStart.parent); + + if (shouldLift) { + currentEnd = candidateBlockRange.start; + + const targetDepth = liftTarget(candidateBlockRange); + if (targetDepth !== null) { + tr.lift(candidateBlockRange, targetDepth); + } + } + } + + currentEnd--; + } + + // sinks the selected list item deeper into the list hierarchy + sink(tr, selectionRange, itemType); + + dispatch(tr.scrollIntoView()); + return true; + } + return false; + }; +} 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, };