diff --git a/src/extensions/markdown/Lists/commands.test.ts b/src/extensions/markdown/Lists/commands.test.ts index ef04bb36..dbc82945 100644 --- a/src/extensions/markdown/Lists/commands.test.ts +++ b/src/extensions/markdown/Lists/commands.test.ts @@ -1,37 +1,34 @@ // eslint-disable-next-line import/no-extraneous-dependencies -import ist from 'ist'; -import type {Node} from 'prosemirror-model'; +import {Schema} from 'prosemirror-model'; +import {builders, li, ul} from 'prosemirror-test-builder'; + 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); -} + getListItemsToTransform, + sinkOnlySelectedListItem, +} from 'src/extensions/markdown/Lists/commands'; +import {apply, assertMapEntries, getParams} from 'src/extensions/markdown/Lists/testUtils'; +import {getSchemaSpecs as getYfmNoteSchemaSpecs} from 'src/extensions/yfm/YfmNote/YfmNoteSpecs/schema'; + +const schema = new Schema({ + nodes: { + doc: {content: 'block+'}, + text: {group: 'inline'}, + paragraph: { + group: 'block', + content: 'inline*', + toDOM: () => ['p', 0], + }, + ...getYfmNoteSchemaSpecs(), + }, +}); + +const { + doc, + paragraph: p, + yfm_note: note, + yfm_note_title: noteTitle, + yfm_note_content: noteContent, +} = builders(schema); describe('sinkOnlySelectedListItem', () => { const sink = sinkOnlySelectedListItem(schema.nodes.list_item); @@ -232,4 +229,155 @@ describe('sinkOnlySelectedListItem', () => { ), ), )); + it('sinks nested list items with a reverse staircase selection from outdented item to indented one', () => + 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')), + li(p('dd'), ul(li(p('ee'), ul(li(p('ss')))), li(p('zz')))), + li(p('ww')), + ), + ), + li(p('pp')), + li(p('hh')), + ), + ), + )); + + it('removes selection markers without changing list structure for first item', () => + apply( + doc(ul(li(p('11')), li(p('22')), li(p('33')))), + sink, + doc(ul(li(p('11')), li(p('22')), li(p('33')))), + )); + + it('indents the second item into a sublist when selected', () => + apply( + doc(ul(li(p('11')), li(p('22')), li(p('33')))), + sink, + doc(ul(li(p('11'), ul(li(p('22')))), li(p('33')))), + )); + + it('indents only the selected item when selection spans two items', () => + apply( + doc(ul(li(p('11')), li(p('22')), li(p('33')))), + sink, + doc(ul(li(p('11'), ul(li(p('22')))), li(p('33')))), + )); +}); + +describe('getListItemsToTransform (using getParams helper)', () => { + it('1', () => { + const testDoc = doc(ul(li(p('11')), li(p('22')), li(p('33')))); + + const resultMap = getListItemsToTransform(...getParams(testDoc)); + + assertMapEntries(resultMap, [[7, 13]]); + }); + + it('2', () => { + const testDoc = doc(ul(li(p('11')), li(p('22')), li(p('33')))); + + const resultMap = getListItemsToTransform(...getParams(testDoc)); + + assertMapEntries(resultMap, [ + [7, 13], + [13, 19], + ]); + }); + + it('3', () => { + const testDoc = doc(ul(li(p('11')), li(p('22'), ul(li(p('33')))))); + const resultMap = getListItemsToTransform(...getParams(testDoc)); + + assertMapEntries(resultMap, [ + [7, 21], + [13, 19], + ]); + }); + + it('4', () => { + const testDoc = doc(ul(li(p('11')), li(p('22'), ul(li(p('33')))), li(p('44')))); + const resultMap = getListItemsToTransform(...getParams(testDoc)); + + assertMapEntries(resultMap, [ + [7, 21], + [13, 19], + [21, 27], + ]); + }); + + it('5', () => { + const testDoc = doc(ul(li(p('11'), ul(li(p('22')), li(p('33')))), li(p('44')))); + const resultMap = getListItemsToTransform(...getParams(testDoc)); + + assertMapEntries(resultMap, [ + [13, 19], + [21, 27], + ]); + }); + + it('6', () => { + const testDoc = doc( + ul( + li(p('11')), + li(p('22'), ul(li(p('33'), ul(li(p('44')))), li(p('55')))), + li(p('66')), + ), + ); + + const resultMap = getListItemsToTransform(...getParams(testDoc)); + + assertMapEntries(resultMap, [ + [7, 35], + [13, 27], + [19, 25], + [27, 33], + [35, 41], + ]); + }); + + it('7', () => { + const testDoc = doc( + ul( + li(p('11')), + li( + p('22'), + note(noteTitle('Note'), noteContent(ul(li(p('33')), li(p('44')), li(p('55'))))), + ), + li(p('66')), + ), + ); + + const resultMap = getListItemsToTransform(...getParams(testDoc)); + + assertMapEntries(resultMap, [ + [7, 43], + [43, 49], + [21, 27], + [27, 33], + [33, 39], + ]); + }); }); diff --git a/src/extensions/markdown/Lists/commands.ts b/src/extensions/markdown/Lists/commands.ts index 33289e5b..608413d8 100644 --- a/src/extensions/markdown/Lists/commands.ts +++ b/src/extensions/markdown/Lists/commands.ts @@ -1,7 +1,8 @@ -import {Fragment, type NodeRange, type NodeType, Slice} from 'prosemirror-model'; +import {Fragment, type Node, 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 {findParentNodeClosestToPos} from 'prosemirror-utils'; import {joinPreviousBlock} from '../../../commands/join'; @@ -30,11 +31,17 @@ export const joinPrevList = joinPreviousBlock({ 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); - + console.warn('sink', '=========>>>'); + const before = range.start; + const after = range.end; + const startIndex = range.startIndex; const parent = range.parent; + + console.log('before', before); + console.log('after', after); + console.log('startIndex', startIndex); + console.log('parent', parent); + const nodeBefore = parent.child(startIndex - 1); const nestedBefore = nodeBefore.lastChild && nodeBefore.lastChild.type === parent.type; @@ -56,65 +63,224 @@ const sink = (tr: Transaction, range: NodeRange, itemType: NodeType) => { true, ), ); + + // Log the new position of the moved
  • + const oldPos = range.start; + const newPos = tr.mapping.map(oldPos, 1); // 1 = map as if the step was inserted after + console.log('[sink] moved
  • new pos:', newPos, 'node:', tr.doc.nodeAt(newPos)?.type.name); + + // Lift any nested lists that ended up inside the moved
  • + liftNestedLists(tr, itemType, parent.type, newPos); + 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; +export const isNotFirstListItemNode = (node: Node, itemType: NodeType) => + node.childCount > 0 && node.firstChild!.type === itemType; + +function findDeepestListItem(tr: Transaction, itemType: NodeType, start: number): [number, number] { + let pos = start; + + while (pos >= 0) { + const node = tr.doc.nodeAt(pos); + + // console.log('pos', pos); + // console.log('node', node?.type.name); + + if (node?.type === itemType) { + console.log('poses:', pos, pos + node.nodeSize); + return [pos, pos + node.nodeSize]; } - const {startIndex, parent, start, end} = selectionRange; - if (startIndex === 0) { - return false; + pos--; + } + + return [start, start]; +} +/** + * Returns a map of list item positions that should be transformed (e.g., sink or lift). + */ +export function getListItemsToTransform( + tr: Transaction, + itemType: NodeType, + {start, end, from, to}: {start: number; end: number; from: number; to: number}, +): Map { + console.warn('getListItemsToTransform', start, end, from, to); + const listItemsPoses = new Map(); + + const [fromStart, fromEnd] = findDeepestListItem(tr, itemType, from); + const [toStart, toEnd] = findDeepestListItem(tr, itemType, to); + + const $from = tr.doc.resolve(from); + const $to = tr.doc.resolve(to); + + const fromParent = findParentNodeClosestToPos($from, (node) => node.type === itemType); + const toParent = findParentNodeClosestToPos($to, (node) => node.type === itemType); + + console.error('+++'); + console.log('form', fromStart, fromEnd); + console.log('to', toStart, toEnd); + console.log( + 'fromParent', + fromParent?.pos, + Number(fromParent?.pos) + Number(fromParent?.node?.nodeSize), + ); + console.log( + 'toParent', + toParent?.pos, + Number(toParent?.pos) + Number(toParent?.node?.nodeSize), + ); + + listItemsPoses.set(fromStart, fromEnd); + listItemsPoses.set(toStart, toEnd); + + let pos = fromStart + 1; + + while (pos < toStart) { + const node = tr.doc.nodeAt(pos); + + console.log('pos', pos); + console.log('node', node?.type.name); + + if (node?.type === itemType) { + listItemsPoses.set(pos, pos + node.nodeSize); + } else if (node && !isListNode(node)) { + pos += node.nodeSize - 1; + } + pos++; + } + + return listItemsPoses; +} + +/** + * Lifts all nested lists (
      /
        ) that are direct children of the list item at `liPos`. + * + * @param tr The working transaction + * @param itemType The node type representing a list_item + * @param listType The node type representing the surrounding list (bullet_list / ordered_list) + * @param liPos The absolute position of the moved
      1. in the current transaction + */ +function liftNestedLists( + tr: Transaction, + itemType: NodeType, + listType: NodeType, + liPos: number, +): void { + console.log('[liftNestedLists] entered with liPos:', liPos); + const movedItem = tr.doc.nodeAt(liPos); + console.log('[liftNestedLists] movedItem at liPos:', movedItem); + if (!movedItem) return; + + movedItem.forEach((child, offset) => { + console.log('[liftNestedLists] inspecting child at offset', offset, 'node:', child); + if (child.type === listType) { + const nestedStart = liPos + 1 + offset; + const nestedEnd = nestedStart + child.nodeSize; + console.log( + '[liftNestedLists] nested list span start/end:', + nestedStart, + nestedEnd, + 'child.nodeSize:', + child.nodeSize, + ); + + const $nestedStart = tr.doc.resolve(nestedStart + 1); + const $nestedEnd = tr.doc.resolve(nestedEnd - 1); + console.log( + '[liftNestedLists] resolving range with $nestedStart.pos:', + $nestedStart.pos, + '$nestedEnd.pos:', + $nestedEnd.pos, + ); + + const liftRange = $nestedStart.blockRange($nestedEnd, (node) => node.type === listType); + console.log( + '[liftNestedLists] liftRange:', + liftRange ? {start: liftRange.start, end: liftRange.end} : null, + ); + if (liftRange) { + const target = liftTarget(liftRange); + console.log('[liftNestedLists] lift target depth:', target); + if (target !== null) { + console.log( + '[liftNestedLists] performing tr.lift on range:', + liftRange.start, + liftRange.end, + 'with target depth:', + target, + ); + tr.lift(liftRange, target); + } + } } + }); +} - const nodeBefore = parent.child(startIndex - 1); - if (nodeBefore.type !== itemType) { +export function sinkOnlySelectedListItem(itemType: NodeType): Command { + return ({tr, selection}, dispatch) => { + const {$from, $to, from, to} = selection; + const listItemSelectionRange = $from.blockRange($to, (node) => + isNotFirstListItemNode(node, itemType), + ); + + if (!listItemSelectionRange) { 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); - } - } + const {start, end} = listItemSelectionRange; + const listItemsPoses = getListItemsToTransform(tr, itemType, { + start, + end, + from, + to, + }); + + console.warn(listItemsPoses, 'start: end', start, end); + + for (const [startPos, endPos] of listItemsPoses) { + const mappedStart = tr.mapping.map(startPos); + const mappedEnd = tr.mapping.map(endPos); + + let j = 0; + while (j < tr.doc.nodeSize - 1) { + const node = tr.doc.nodeAt(j); + // console.log('node', j, node?.type.name); + j++; } - currentEnd--; - } + const start = mappedStart; + const end = mappedEnd; - // sinks the selected list item deeper into the list hierarchy - sink(tr, selectionRange, itemType); + const startNode = tr.doc.nodeAt(start); + const $start = tr.doc.resolve(start); + const $end = tr.doc.resolve(end); + console.log('[startPos: endPos]', startPos, endPos); + console.log('[mapped startPos: endPos]', mappedStart, mappedEnd); + console.log('[$start, $end]', $start.pos, $end.pos, 'j:', j); + console.log('[startNode]', startNode?.type, 'startNode size', startNode?.nodeSize); + console.log( + '[start, end]', + start, + end, + 'start + nodeSize', + start + (startNode?.nodeSize ?? 0), + ); + + const range = $start.blockRange($end); + + if (range) { + console.log('[sink ---->]', range.start, range.end, range); + // sink(tr, range, itemType); + } + } dispatch(tr.scrollIntoView()); + return true; } + return true; }; } diff --git a/src/extensions/markdown/Lists/testUtils.ts b/src/extensions/markdown/Lists/testUtils.ts new file mode 100644 index 00000000..920f41f0 --- /dev/null +++ b/src/extensions/markdown/Lists/testUtils.ts @@ -0,0 +1,89 @@ +import ist from 'ist'; +import type {Node} from 'prosemirror-model'; +import { + type Command, + EditorState, + NodeSelection, + Selection, + TextSelection, +} from 'prosemirror-state'; +import {eq, schema} from 'prosemirror-test-builder'; + +import {liType} from 'src/extensions'; +import {isNotFirstListItemNode} from 'src/extensions/markdown/Lists/commands'; + +export 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); +} + +export 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); + + if (result && (result as any).tag.a !== null) { + ist(state.selection, selFor(result), eq); + } +} + +type Tags = {[tag: string]: number}; + +export function getParams(docNode: Node & {tag: Tags}) { + const state = EditorState.create({ + doc: docNode, + selection: TextSelection.create(docNode, docNode.tag.a, docNode.tag.b), + }); + const {tr, selection} = state; + const {$from, $to, from, to} = selection; + const itemType = liType(schema); + const range = $from.blockRange($to, (node) => isNotFirstListItemNode(node, itemType)); + + if (!range) { + return [tr, schema.nodes.list_item, {start: 0, end: 0, from: 0, to: 0}] as const; + } + + return [tr, schema.nodes.list_item, {start: range.start, end: range.end, from, to}] as const; +} + +export function assertMapEntries( + resultMap: Map, + expectedEntries: Array<[number, number]>, +) { + // 1) Check that there are no extra or missing entries. + console.log('111-3', resultMap.size); + console.log('111-4', expectedEntries.length); + + console.log('111-5', resultMap.size === expectedEntries.length); + + const x = resultMap.size; + const y = expectedEntries.length; + + ist(x, y); + + // 2) For each expected [key, value], verify presence and equality. + for (const [key, value] of expectedEntries) { + // Ensure the key exists + console.log('==--==--==-->', resultMap); + console.log('222-1', key); + console.log('222-2', resultMap.has(key)); + console.log( + '222-3', + resultMap.forEach((key) => console.log(key)), + ); + ist(resultMap.has(key), true); + + // Ensure the stored value matches + ist(resultMap.get(key), value); + console.log('333-1', ist(resultMap.get(key), value)); + } +}