Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions third_party/muya/src/clipboard/__tests__/pasteListMerge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
});
});
55 changes: 44 additions & 11 deletions third_party/muya/src/clipboard/paste.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Parent>, 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')
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down
Loading