From 9bd2a60faf132535336dcbd5865eddbea3b84648 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Wed, 14 May 2025 18:25:51 +0200 Subject: [PATCH 1/8] test: added test for list items with a reverse staircase selection --- .../markdown/Lists/commands.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/extensions/markdown/Lists/commands.test.ts b/src/extensions/markdown/Lists/commands.test.ts index ef04bb36..d5921bcc 100644 --- a/src/extensions/markdown/Lists/commands.test.ts +++ b/src/extensions/markdown/Lists/commands.test.ts @@ -232,4 +232,39 @@ 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')), + ), + ), + )); }); From 3ac5417b436fc61a9938904a33e98be47ca67b04 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Wed, 21 May 2025 10:06:53 +0200 Subject: [PATCH 2/8] feat: added getListItemsToTransform to detect affected items, updated sink to prevent deep nesting --- .../markdown/Lists/commands.test.ts | 21 +++ src/extensions/markdown/Lists/commands.ts | 170 +++++++++++++----- 2 files changed, 144 insertions(+), 47 deletions(-) diff --git a/src/extensions/markdown/Lists/commands.test.ts b/src/extensions/markdown/Lists/commands.test.ts index d5921bcc..2cbc524d 100644 --- a/src/extensions/markdown/Lists/commands.test.ts +++ b/src/extensions/markdown/Lists/commands.test.ts @@ -267,4 +267,25 @@ describe('sinkOnlySelectedListItem', () => { ), ), )); + + 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')))), + )); }); diff --git a/src/extensions/markdown/Lists/commands.ts b/src/extensions/markdown/Lists/commands.ts index 33289e5b..bbc10f6c 100644 --- a/src/extensions/markdown/Lists/commands.ts +++ b/src/extensions/markdown/Lists/commands.ts @@ -1,4 +1,4 @@ -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'; @@ -30,11 +30,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 +62,135 @@ const sink = (tr: Transaction, range: NodeRange, itemType: NodeType) => { true, ), ); + + // After sinking, lift any nested
  • children back out + const from = range.start; + const $movedPos = tr.doc.resolve(from); + const movedItem = $movedPos.nodeAfter; + + if (movedItem) { + movedItem.forEach((child, offset) => { + if (child.type === parent.type) { + const nestedStart = from + offset + 1; + const nestedEnd = nestedStart + child.nodeSize; + const $liStart = tr.doc.resolve(nestedStart + 1); + const $liEnd = tr.doc.resolve(nestedEnd - 1); + const liftRange = $liStart.blockRange($liEnd, (node) => node.type === itemType); + + if (liftRange) { + const targetDepth = liftTarget(liftRange); + if (targetDepth !== null) { + tr.lift(liftRange, targetDepth); + } + } + } + }); + } + return true; }; +const isListItemNode = (node: Node, itemType: NodeType) => + node.childCount > 0 && node.firstChild!.type === itemType; + +/** + * Returns a map of list item positions that should be transformed (e.g., sink or lift). + */ +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(); + let pos = start; + + while (pos <= end) { + const node = tr.doc.nodeAt(pos); + + // console.log('pos', pos); + // console.log('node', node?.type.name); + + if (node?.type === itemType) { + // console.log('list pos ----->: ', pos, pos + node.nodeSize); + const isBeetwwen = + (pos <= from && pos + node.nodeSize >= from) || + (pos <= to && pos + node.nodeSize >= to); + if (isBeetwwen) { + // console.warn(isBeetwwen); + listItemsPoses.set(pos, pos + node.nodeSize); + } else { + // console.log(isBeetwwen, pos, pos + node.nodeSize, 'from:to', from, to); + } + } + + pos++; + } + + return listItemsPoses; +} + 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, + const {$from, $to, from, to} = selection; + const listItemSelectionRange = $from.blockRange($to, (node) => + isListItemNode(node, 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) { + 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, + }); - currentEnd--; - } + console.warn(listItemsPoses, 'start: end', start, end); + + for (const [startPos, endPos] of listItemsPoses) { + const mappedStart = tr.mapping.map(startPos); + const nodeStart = tr.doc.nodeAt(mappedStart); - // sinks the selected list item deeper into the list hierarchy - sink(tr, selectionRange, itemType); + const mappedEnd = tr.mapping.map(endPos); + // const nodeEnd = tr.doc.nodeAt(mappedEnd); + console.log('startPos ---->', startPos); + console.log('endPos ---->', endPos); + + console.log('mapped startPos ---->', mappedStart); + console.log('mapped endPos ---->', mappedEnd); + + console.log('nodeStart ---->', nodeStart?.type.name); + + const $mappedStart = tr.doc.resolve(mappedStart); + const $mappedEnd = tr.doc.resolve(mappedEnd); + const range = $mappedStart.blockRange($mappedEnd); + + if (range) { + console.log('sink ---->', range.start, range.end, range); + sink(tr, range, itemType); + } + } dispatch(tr.scrollIntoView()); + return true; } + return true; }; } From 7117a433502ca14ed720e576c3181add3a519fb0 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Wed, 21 May 2025 16:18:18 +0200 Subject: [PATCH 3/8] feat: added debug code --- src/extensions/markdown/Lists/commands.ts | 38 ++++++++++++++++------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/extensions/markdown/Lists/commands.ts b/src/extensions/markdown/Lists/commands.ts index bbc10f6c..83ca13aa 100644 --- a/src/extensions/markdown/Lists/commands.ts +++ b/src/extensions/markdown/Lists/commands.ts @@ -164,25 +164,39 @@ export function sinkOnlySelectedListItem(itemType: NodeType): Command { for (const [startPos, endPos] of listItemsPoses) { const mappedStart = tr.mapping.map(startPos); - const nodeStart = tr.doc.nodeAt(mappedStart); - const mappedEnd = tr.mapping.map(endPos); - // const nodeEnd = tr.doc.nodeAt(mappedEnd); - console.log('startPos ---->', startPos); - console.log('endPos ---->', endPos); + console.log('startPos: endPos', startPos, endPos); + console.log('mapped startPos: endPos ', mappedStart, mappedEnd); + + let j = 0; + while (j < tr.doc.nodeSize - 1) { + const node = tr.doc.nodeAt(j); + console.log('node', j, node?.type.name); + j++; + } + + const start = startPos; + const end = endPos; - console.log('mapped startPos ---->', mappedStart); - console.log('mapped endPos ---->', mappedEnd); + const startNode = tr.doc.nodeAt(start); + console.log('[startNode]', startNode?.type, 'startNode size', startNode?.nodeSize); + console.log( + '[start, end]', + start, + end, + 'start + nodeSize', + start + (startNode?.nodeSize ?? 0), + ); - console.log('nodeStart ---->', nodeStart?.type.name); + const $start = tr.doc.resolve(start); + const $end = tr.doc.resolve(end); - const $mappedStart = tr.doc.resolve(mappedStart); - const $mappedEnd = tr.doc.resolve(mappedEnd); - const range = $mappedStart.blockRange($mappedEnd); + console.log('[$start, $end]', $start.pos, $end.pos, 'j:', j); + const range = $start.blockRange($end); if (range) { - console.log('sink ---->', range.start, range.end, range); + console.log('[sink ---->]', range.start, range.end, range); sink(tr, range, itemType); } } From 9316d8acdd93288cfced11e20e85b17ab54fa52a Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Thu, 22 May 2025 10:16:27 +0200 Subject: [PATCH 4/8] feat: added debug code --- src/extensions/markdown/Lists/commands.ts | 92 +++++++++++++++-------- 1 file changed, 60 insertions(+), 32 deletions(-) diff --git a/src/extensions/markdown/Lists/commands.ts b/src/extensions/markdown/Lists/commands.ts index 83ca13aa..568a1c72 100644 --- a/src/extensions/markdown/Lists/commands.ts +++ b/src/extensions/markdown/Lists/commands.ts @@ -63,29 +63,13 @@ const sink = (tr: Transaction, range: NodeRange, itemType: NodeType) => { ), ); - // After sinking, lift any nested
  • children back out - const from = range.start; - const $movedPos = tr.doc.resolve(from); - const movedItem = $movedPos.nodeAfter; - - if (movedItem) { - movedItem.forEach((child, offset) => { - if (child.type === parent.type) { - const nestedStart = from + offset + 1; - const nestedEnd = nestedStart + child.nodeSize; - const $liStart = tr.doc.resolve(nestedStart + 1); - const $liEnd = tr.doc.resolve(nestedEnd - 1); - const liftRange = $liStart.blockRange($liEnd, (node) => node.type === itemType); - - if (liftRange) { - const targetDepth = liftTarget(liftRange); - if (targetDepth !== null) { - tr.lift(liftRange, targetDepth); - } - } - } - }); - } + // 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; }; @@ -140,6 +124,51 @@ function getListItemsToTransform( return listItemsPoses; } +/** + * Lifts all nested lists (