From bb9c5cb9c738a7dd37603f0346919b2cd40ffa64 Mon Sep 17 00:00:00 2001 From: SecPan Date: Wed, 8 Jul 2026 13:39:01 -0700 Subject: [PATCH] Port upstream list paste ordering fix --- .../__tests__/pasteListMerge.spec.ts | 20 +++++++ third_party/muya/src/clipboard/paste.ts | 55 +++++++++++++++---- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/third_party/muya/src/clipboard/__tests__/pasteListMerge.spec.ts b/third_party/muya/src/clipboard/__tests__/pasteListMerge.spec.ts index 3eb8d1a..c94c721 100644 --- a/third_party/muya/src/clipboard/__tests__/pasteListMerge.spec.ts +++ b/third_party/muya/src/clipboard/__tests__/pasteListMerge.spec.ts @@ -133,4 +133,24 @@ describe('paste — same-type list merges into the enclosing list (A5, muyajs pa expect(selection.anchor?.offset).toBe(4); expect(selection.focus?.offset).toBe(4); }); + + // #3549: pasting into a non-last item must keep the pasted items in order + // right after the anchor item, not append them to the end of the list. + it('inserts pasted items after the anchor item, not at the list end', async () => { + const muya = bootMuya('- Item A\n- \n- Item B\n'); + const empty = contentBlocks(muya).find(b => b.text === '')!; + expect(await paste(muya, empty, 0, 0, '- Item 1\n- Item 2\n- Item 3')).toBe( + '- Item A\n- Item 1\n- Item 2\n- Item 3\n- Item B\n', + ); + }); + + it('keeps order for an ordered list pasted into the middle', async () => { + const muya = bootMuya('1. A\n2. \n3. B\n'); + const empty = contentBlocks(muya).find(b => b.text === '')!; + // Android preserves source ordered-list markers on serialization, so + // this locks item order rather than forcing renumbering policy. + expect(await paste(muya, empty, 0, 0, '1. one\n2. two')).toBe( + '1. A\n2. one\n2. two\n3. B\n', + ); + }); }); diff --git a/third_party/muya/src/clipboard/paste.ts b/third_party/muya/src/clipboard/paste.ts index 2eb4a3b..18517ba 100644 --- a/third_party/muya/src/clipboard/paste.ts +++ b/third_party/muya/src/clipboard/paste.ts @@ -239,6 +239,38 @@ function itemParaContent(list: Parent, itemIndex: number, paraIndex: number): Nu return para?.firstContentInDescendant() ?? null; } +interface IListMergeSeam { + foldedOnly: boolean; + itemIndex: number; + paraIndex: number; + head: string; + sewOffset: number; + canFold: boolean; + pastedCount: number; + trailingStates: TState[]; +} + +// Seat the caret after a list-merge rebuilds the list block: inside the folded +// paragraph, on trailing non-list content, or on the last pasted item. +function seatListMergeCursor(muya: Muya, newList: Parent, seam: IListMergeSeam): void { + const { foldedOnly, itemIndex, paraIndex, head, sewOffset, canFold, pastedCount, trailingStates } = seam; + if (foldedOnly) { + const cursor = itemParaContent(newList, itemIndex, paraIndex); + const offset = head.length + sewOffset; + cursor?.setCursor(offset, offset, true); + + return; + } + if (trailingStates.length > 0) { + const last = insertStatesAfter(muya, newList, trailingStates); + seatCursorAtSeam(last, sewOffset); + + return; + } + const lastPastedIndex = itemIndex + pastedCount - (canFold ? 1 : 0); + seatCursorAtSeam(newList.find(lastPastedIndex) as Nullable, sewOffset); +} + // Same list kind + same bullet marker / order delimiter. function listMarkersMatch(a: TState, b: TState): boolean { if (a.name === 'order-list' && b.name === 'order-list') @@ -301,7 +333,7 @@ function tryMergeListPaste( if (canFold) { anchorPara.text = head + pastedFirst.text; currentItem.children = [...currentItem.children, ...pastedItems[0].children.slice(1)]; - mergedChildren.push(...pastedItems.slice(1)); + mergedChildren.splice(itemIndex + 1, 0, ...pastedItems.slice(1)); // The whole paste folded into `anchorPara` (no extra blocks/items): the // caret stays in that paragraph at the seam. foldedOnly @@ -311,7 +343,7 @@ function tryMergeListPaste( } else { anchorPara.text = head; - mergedChildren.push(...pastedItems); + mergedChildren.splice(itemIndex + 1, 0, ...pastedItems); } const loose = listState.meta.loose || firstState.meta.loose; @@ -327,15 +359,16 @@ function tryMergeListPaste( ); listBlock.replaceWith(newList); - if (foldedOnly) { - const cursor = itemParaContent(newList, itemIndex, paraIndex); - const offset = head.length + sewOffset; - cursor?.setCursor(offset, offset, true); - } - else { - const last = insertStatesAfter(clipboard.muya, newList, states.slice(1)); - seatCursorAtSeam(last, sewOffset); - } + seatListMergeCursor(clipboard.muya, newList as Parent, { + foldedOnly, + itemIndex, + paraIndex, + head, + sewOffset, + canFold, + pastedCount: pastedItems.length, + trailingStates: states.slice(1), + }); return true; }