Skip to content

Mobile, Desktop: Fixes #13102: Keep list type when reordering items via drag and drop#15038

Open
Ashutoshx7 wants to merge 2 commits intolaurent22:devfrom
Ashutoshx7:fix/keep-list-type-on-drag-drop
Open

Mobile, Desktop: Fixes #13102: Keep list type when reordering items via drag and drop#15038
Ashutoshx7 wants to merge 2 commits intolaurent22:devfrom
Ashutoshx7:fix/keep-list-type-on-drag-drop

Conversation

@Ashutoshx7
Copy link
Copy Markdown
Contributor

Fixes #13102

Problem
when dragging list items to reorder them in the rich text editor , the list type would change example bullet list would become numbered list after a frag

Solution
Fixed by adjusting the drop position so the item lands inside the existing list instead of outside it. Falls back to default behavior for non-list drags. Added unit tests.

Screencast.From.2026-04-07.03-24-02.mp4

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 6, 2026

📝 Walkthrough

Walkthrough

Intercepts ProseMirror drop events for moved NodeSelections; computes a candidate insertion via posAtCoords + dropPoint, adjusts list-item insert positions with adjustListItemDropInsertPos for single closed list-item slices, replaces the dragged node at the adjusted position, updates selection and focus, dispatches a transaction with uiEvent: 'drop', and prevents default when the document changes.

Changes

Cohort / File(s) Summary
Editor drop handling
packages/editor/ProseMirror/createEditor.ts
Added local drag-state plumbing and an override of EditorView's handleDrop to compute posAtCoords/dropPoint, call adjustListItemDropInsertPos, remap/clamp positions, replace the dragged node via replaceRangeWith/replaceRange, set selection/focus, dispatch with uiEvent: 'drop', and prevent default when the doc changes.
List-item adjust utility & tests
packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.ts, packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.test.ts
New default-exported adjustListItemDropInsertPos(doc, insertPos, slice) that detects single closed list_item/task_list_item slices and nudges insertion (+1 or -1) when adjacent nodes accept the type; Jest tests verify list-type preservation and non-list behavior.
Ignore patterns
.eslintignore, .gitignore
Added generated JS/test artifacts for the new utility to ignore lists (adjustListItemDropInsertPos.js, adjustListItemDropInsertPos.test.js).

Sequence Diagram(s)

sequenceDiagram
  participant User as User (drag/drop)
  participant View as EditorView
  participant DragState as DraggingState
  participant PosCalc as PositionCalc (posAtCoords/dropPoint)
  participant Adjust as adjustListItemDropInsertPos
  participant Doc as Document/State
  participant Dispatcher as TransactionDispatcher
  participant Browser as Browser

  User->>View: drop event (coords, dataTransfer)
  View->>DragState: read view.dragging (moved? NodeSelection?)
  alt moved + node present
    View->>PosCalc: posAtCoords -> dropPoint(slice)
    PosCalc->>Adjust: provide doc, insertPos, slice
    Adjust->>View: return adjustedInsertPos
    View->>Doc: create transaction (replaceRangeWith / replaceRange)
    Doc->>Dispatcher: dispatch(tr with uiEvent:'drop')
    Dispatcher->>View: set selection near insertion, focus
    View->>Browser: preventDefault()
  else not a moved NodeSelection
    View-->>Browser: return false (allow default)
  end
Loading

Suggested labels: mobile, desktop

🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: fixing issue #13102 by preserving list type when reordering items via drag and drop, which aligns with the changeset modifications to ProseMirror editor drag-drop handling.
Description check ✅ Passed The description is related to the changeset, explaining the problem with list type changes during drag-and-drop reordering and the solution of adjusting drop position with added unit tests.
Linked Issues check ✅ Passed The pull request addresses issue #13102 by implementing drag-and-drop position adjustment logic and unit tests that preserve list types when reordering items, meeting the expected behaviour requirement.
Out of Scope Changes check ✅ Passed All changes are within scope: core drag-drop handler update, new utility function for list-item position adjustment, unit tests, and build configuration updates (.eslintignore and .gitignore) directly support the fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Pr Description Must Follow Guidelines ✅ Passed The PR description includes all three required sections: problem statement, solution explanation, and verification through unit tests, aligning with Joplin's PR guidelines.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai bot added bug It's a bug editor labels Apr 6, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.test.ts (1)

77-98: Parameterise the list-type variants.

These two cases exercise the same behaviour with different fixtures, so a test.each table would remove the repetition and make the task_list_item path cheap to add as another row later.

♻️ Suggested refactor
-	test('keeps dragged bullet list items in a bullet list at list boundary drop positions', () => {
-		const state = makeState('bullet_list');
-		const unadjusted = moveSecondItemAboveFirst(state, false);
-		const adjusted = moveSecondItemAboveFirst(state, true);
-
-		expect(unadjusted.firstChild.type).toBe(schema.nodes.paragraph);
-		expect(unadjusted.child(1).type).toBe(schema.nodes.ordered_list);
-
-		expect(adjusted.firstChild.type).toBe(schema.nodes.paragraph);
-		expect(adjusted.child(1).type).toBe(schema.nodes.bullet_list);
-		expect(listItemTexts(adjusted.child(1))).toStrictEqual(['two', 'one']);
-	});
-
-	test('keeps dragged ordered list items in an ordered list at list boundary drop positions', () => {
-		const state = makeState('ordered_list');
-		const adjusted = moveSecondItemAboveFirst(state, true);
-
-		expect(adjusted.firstChild.type).toBe(schema.nodes.paragraph);
-		expect(adjusted.child(1).type).toBe(schema.nodes.ordered_list);
-		expect(adjusted.childCount).toBe(2);
-		expect(listItemTexts(adjusted.child(1))).toStrictEqual(['two', 'one']);
-	});
+	test.each([
+		{
+			listType: 'bullet_list' as const,
+			expectedAdjustedType: schema.nodes.bullet_list,
+			expectedUnadjustedType: schema.nodes.ordered_list,
+		},
+		{
+			listType: 'ordered_list' as const,
+			expectedAdjustedType: schema.nodes.ordered_list,
+			expectedUnadjustedType: undefined,
+		},
+	])(
+		'keeps dragged $listType items in their original list type at list boundary drop positions',
+		({ listType, expectedAdjustedType, expectedUnadjustedType }) => {
+			const state = makeState(listType);
+			const unadjusted = moveSecondItemAboveFirst(state, false);
+			const adjusted = moveSecondItemAboveFirst(state, true);
+
+			expect(adjusted.firstChild.type).toBe(schema.nodes.paragraph);
+			expect(adjusted.child(1).type).toBe(expectedAdjustedType);
+			expect(adjusted.childCount).toBe(2);
+			expect(listItemTexts(adjusted.child(1))).toStrictEqual(['two', 'one']);
+
+			if (expectedUnadjustedType) {
+				expect(unadjusted.firstChild.type).toBe(schema.nodes.paragraph);
+				expect(unadjusted.child(1).type).toBe(expectedUnadjustedType);
+			}
+		},
+	);
As per coding guidelines: "Avoid duplicating code in tests; use test.each or shared helpers instead of repeating similar test blocks."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.test.ts` around
lines 77 - 98, Replace the two near-duplicate tests with a single parameterized
test using test.each: iterate over rows like ['bullet_list',
schema.nodes.bullet_list] and ['ordered_list', schema.nodes.ordered_list], call
makeState(listType) and moveSecondItemAboveFirst(state, true) for each row, and
assert the same expectations (firstChild is paragraph, child(1) is the expected
list node, childCount where needed, and listItemTexts matches ['two','one']);
reuse moveSecondItemAboveFirst, makeState, listItemTexts and schema node
references so adding task_list_item is just adding another row to the table.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/editor/ProseMirror/createEditor.ts`:
- Around line 198-199: Replace the direct call to view.focus() inside the custom
drop branch with the module's existing focus helper (instead of calling
view.focus() directly) so focus bookkeeping is consistent; after dispatching
tr.setMeta('uiEvent', 'drop') call the existing focus handler used elsewhere in
this file (the same helper the rest of the module uses) rather than
view.focus().

In `@packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.ts`:
- Around line 28-34: The current logic in adjustListItemDropInsertPos uses two
separate checks on $pos.nodeAfter and $pos.nodeBefore so when both sides are
compatible the nodeAfter branch wins and flips append/prepend semantics; change
this to compute booleans (e.g. afterOk =
$pos.nodeAfter?.type.contentMatch.matchType(draggedListItemType), beforeOk =
$pos.nodeBefore?.type.contentMatch.matchType(draggedListItemType)) and only
adjust insertPos when exactly one side is compatible: if afterOk && !beforeOk
return insertPos + 1; else if beforeOk && !afterOk return insertPos - 1;
otherwise return insertPos unchanged.

---

Nitpick comments:
In `@packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.test.ts`:
- Around line 77-98: Replace the two near-duplicate tests with a single
parameterized test using test.each: iterate over rows like ['bullet_list',
schema.nodes.bullet_list] and ['ordered_list', schema.nodes.ordered_list], call
makeState(listType) and moveSecondItemAboveFirst(state, true) for each row, and
assert the same expectations (firstChild is paragraph, child(1) is the expected
list node, childCount where needed, and listItemTexts matches ['two','one']);
reuse moveSecondItemAboveFirst, makeState, listItemTexts and schema node
references so adding task_list_item is just adding another row to the table.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d3427f51-e4ce-4f2f-b5da-4287ebe6ef67

📥 Commits

Reviewing files that changed from the base of the PR and between f0a7f55 and cd01256.

📒 Files selected for processing (3)
  • packages/editor/ProseMirror/createEditor.ts
  • packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.test.ts
  • packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.ts

Comment thread packages/editor/ProseMirror/createEditor.ts Outdated
Comment thread packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.ts
@Ashutoshx7 Ashutoshx7 force-pushed the fix/keep-list-type-on-drag-drop branch from cd01256 to deecd5d Compare April 6, 2026 22:31
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.test.ts (1)

77-98: Reduce duplicated adjusted-list assertions across these tests.

The adjusted-result assertions are repeated; extracting a shared helper will keep intent clearer and future edits safer.

Suggested refactor
+const expectAdjustedListResult = (
+	doc: ProseMirrorNode,
+	listType: 'bullet_list' | 'ordered_list',
+) => {
+	expect(doc.firstChild.type).toBe(schema.nodes.paragraph);
+	expect(doc.child(1).type).toBe(schema.nodes[listType]);
+	expect(listItemTexts(doc.child(1))).toStrictEqual(['two', 'one']);
+};
+
 describe('adjustListItemDropInsertPos', () => {
 	test('keeps dragged bullet list items in a bullet list at list boundary drop positions', () => {
 		const state = makeState('bullet_list');
 		const unadjusted = moveSecondItemAboveFirst(state, false);
 		const adjusted = moveSecondItemAboveFirst(state, true);

 		expect(unadjusted.firstChild.type).toBe(schema.nodes.paragraph);
 		expect(unadjusted.child(1).type).toBe(schema.nodes.ordered_list);

-		expect(adjusted.firstChild.type).toBe(schema.nodes.paragraph);
-		expect(adjusted.child(1).type).toBe(schema.nodes.bullet_list);
-		expect(listItemTexts(adjusted.child(1))).toStrictEqual(['two', 'one']);
+		expectAdjustedListResult(adjusted, 'bullet_list');
 	});

 	test('keeps dragged ordered list items in an ordered list at list boundary drop positions', () => {
 		const state = makeState('ordered_list');
 		const adjusted = moveSecondItemAboveFirst(state, true);

-		expect(adjusted.firstChild.type).toBe(schema.nodes.paragraph);
-		expect(adjusted.child(1).type).toBe(schema.nodes.ordered_list);
+		expectAdjustedListResult(adjusted, 'ordered_list');
 		expect(adjusted.childCount).toBe(2);
-		expect(listItemTexts(adjusted.child(1))).toStrictEqual(['two', 'one']);
 	});

As per coding guidelines, "Avoid duplicating code in tests; use test.each or shared helpers instead of repeating similar test blocks".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.test.ts` around
lines 77 - 98, Two tests duplicate the same "adjusted" assertions; extract a
small shared helper (e.g., assertAdjustedList) and call it from both tests (or
convert to test.each) to remove the repeated checks. Implement the helper to
accept the state or adjusted doc produced by moveSecondItemAboveFirst(state,
true) and assert the firstChild is schema.nodes.paragraph, the second child is
the expected list type (schema.nodes.bullet_list or schema.nodes.ordered_list),
that childCount (if needed) equals 2, and that listItemTexts(...) equals
['two','one']; update the tests to call makeState and moveSecondItemAboveFirst
then delegate the duplicated expect calls to this helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.test.ts`:
- Around line 77-98: Two tests duplicate the same "adjusted" assertions; extract
a small shared helper (e.g., assertAdjustedList) and call it from both tests (or
convert to test.each) to remove the repeated checks. Implement the helper to
accept the state or adjusted doc produced by moveSecondItemAboveFirst(state,
true) and assert the firstChild is schema.nodes.paragraph, the second child is
the expected list type (schema.nodes.bullet_list or schema.nodes.ordered_list),
that childCount (if needed) equals 2, and that listItemTexts(...) equals
['two','one']; update the tests to call makeState and moveSecondItemAboveFirst
then delegate the duplicated expect calls to this helper.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: aec3b227-2625-4919-b767-861f4c3c68be

📥 Commits

Reviewing files that changed from the base of the PR and between cd01256 and deecd5d.

📒 Files selected for processing (5)
  • .eslintignore
  • .gitignore
  • packages/editor/ProseMirror/createEditor.ts
  • packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.test.ts
  • packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.ts
✅ Files skipped from review due to trivial changes (2)
  • .eslintignore
  • .gitignore
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.ts
  • packages/editor/ProseMirror/createEditor.ts

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 6, 2026

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@Ashutoshx7 Ashutoshx7 force-pushed the fix/keep-list-type-on-drag-drop branch from deecd5d to 4586ff7 Compare April 6, 2026 22:46
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/editor/ProseMirror/createEditor.ts`:
- Around line 194-203: The current drop handler always calls
event.preventDefault() and returns true even when no transaction was applied
(when tr.doc.eq(beforeInsert) is true), preventing ProseMirror’s fallback drop
handling; update the logic so that event.preventDefault() and the true return
happen only inside the branch where a change was made (the block guarded by if
(!tr.doc.eq(beforeInsert)) that computes mappedPos/clampedPos, calls
tr.setSelection, focus('createEditor', view), and
view.dispatch(tr.setMeta('uiEvent','drop'))), and let the function fall through
(no preventDefault/return true) when the transaction did not change the
document.

In `@packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.test.ts`:
- Around line 68-72: The test uses Array.prototype.forEach on listNode which
violates the github/array-foreach lint rule; replace the forEach call with a
classic for loop that iterates over listNode (e.g., for (let i = 0; i <
listNode.length; i++)) and inside the loop check if listNode[i].type ===
schema.nodes.list_item and then push listNode[i].textContent into result
(keeping the existing logic around result and schema.nodes.list_item and
avoiding use of .forEach).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 746975e8-6830-47e2-9a6c-6a394deb07ea

📥 Commits

Reviewing files that changed from the base of the PR and between deecd5d and 4586ff7.

📒 Files selected for processing (5)
  • .eslintignore
  • .gitignore
  • packages/editor/ProseMirror/createEditor.ts
  • packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.test.ts
  • packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.ts
✅ Files skipped from review due to trivial changes (3)
  • .eslintignore
  • .gitignore
  • packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.ts

Comment thread packages/editor/ProseMirror/createEditor.ts Outdated
Comment thread packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.test.ts Outdated
@coderabbitai coderabbitai bot added mobile All mobile platforms desktop All desktop platforms and removed bug It's a bug editor labels Apr 7, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/editor/ProseMirror/createEditor.ts (1)

187-192: Add non-null assertion for type safety.

When childCount === 1, firstChild is guaranteed to exist at runtime, but TypeScript types it as Node | null. Adding a non-null assertion makes the intent explicit.

♻️ Suggested fix
 const isNodeSlice = slice.openStart === 0 && slice.openEnd === 0 && slice.content.childCount === 1;
 if (isNodeSlice) {
-	tr.replaceRangeWith(insertPos, insertPos, slice.content.firstChild);
+	tr.replaceRangeWith(insertPos, insertPos, slice.content.firstChild!);
 } else {
 	tr.replaceRange(insertPos, insertPos, slice);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/editor/ProseMirror/createEditor.ts` around lines 187 - 192, The code
branches on isNodeSlice but uses slice.content.firstChild which TypeScript types
as Node | null; update the node access to assert non-null when childCount === 1
by replacing slice.content.firstChild with slice.content.firstChild! inside the
isNodeSlice branch (the block that calls tr.replaceRangeWith(insertPos,
insertPos, slice.content.firstChild)) so the intent is explicit and the compiler
knows the node is present.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/editor/ProseMirror/createEditor.ts`:
- Around line 187-192: The code branches on isNodeSlice but uses
slice.content.firstChild which TypeScript types as Node | null; update the node
access to assert non-null when childCount === 1 by replacing
slice.content.firstChild with slice.content.firstChild! inside the isNodeSlice
branch (the block that calls tr.replaceRangeWith(insertPos, insertPos,
slice.content.firstChild)) so the intent is explicit and the compiler knows the
node is present.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: cab96e81-e0e8-47bd-a805-6ad8d4f5bd72

📥 Commits

Reviewing files that changed from the base of the PR and between 4586ff7 and 2e98808.

📒 Files selected for processing (2)
  • packages/editor/ProseMirror/createEditor.ts
  • packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/editor/ProseMirror/utils/adjustListItemDropInsertPos.test.ts

@personalizedrefrigerator
Copy link
Copy Markdown
Collaborator

Thank you for the pull request!

Screencast.From.2026-04-07.03-24-02.mp4

Be aware that the above screen recording shows the desktop TinyMCE-based editor, while this pull request adjusts the mobile ProseMirror-based editor. Please ensure that testing has been done with the correct editor.

@laurent22
Copy link
Copy Markdown
Owner

Closing stale PR

@laurent22 laurent22 closed this Apr 14, 2026
@Ashutoshx7
Copy link
Copy Markdown
Contributor Author

Ashutoshx7 commented Apr 14, 2026

@laurent22 can you reopen this pr i am working on this

@laurent22 laurent22 reopened this Apr 14, 2026
@Ashutoshx7
Copy link
Copy Markdown
Contributor Author

Thank you for the pull request!

Screencast.From.2026-04-07.03-24-02.mp4

Be aware that the above screen recording shows the desktop TinyMCE-based editor, while this pull request adjusts the mobile ProseMirror-based editor. Please ensure that testing has been done with the correct editor.

Screencast.From.2026-04-16.22-51-18.mp4

sorry for the late reply
just tested this out in mobile ProseMirror-based edito

@personalizedrefrigerator
Copy link
Copy Markdown
Collaborator

just tested this out in mobile ProseMirror-based edito

Note: the attached screen recording shows the CodeMirror-based mobile Markdown editor. To switch to the Rich Text Editor, open the three dots menu at the top right of the screen, then click "edit as rich text".

@Ashutoshx7
Copy link
Copy Markdown
Contributor Author

just tested this out in mobile ProseMirror-based edito

Note: the attached screen recording shows the CodeMirror-based mobile Markdown editor. To switch to the Rich Text Editor, open the three dots menu at the top right of the screen, then click "edit as rich text".

Screencast.From.2026-04-16.23-47-38.mp4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

desktop All desktop platforms mobile All mobile platforms

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mobile: Rich Text Editor: Re-ordering list items with drag-and-drop changes list type

3 participants