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
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
type: Fix

scope:
- ckeditor5-paste-from-office

closes:
- ckeditor/ckeditor5#19127
---

Fixed incorrect structure of nested lists pasted from Word when plain paragraphs appear between nested list items.

Previously, all paragraphs were placed after the nested list items instead of between them. The fix also ensures that interrupted nested ordered lists continue numbering correctly across the paragraph breaks.
63 changes: 50 additions & 13 deletions packages/ckeditor5-paste-from-office/src/filters/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,13 @@ export function transformListItemLikeElementsIntoLists(
return;
}

const encounteredLists: Record<string, number> = {};
// Tracks how many items have been added to each encountered list, keyed by indent level and list ID.
// Used to set the `start` attribute on a new <ol> when a list at a given indent is interrupted by
// a non-list block (e.g. a paragraph) and then resumed.
// Structure: [ { [listId:level]: itemCount } ] (array index is the indent level)
// Example: [ { '1:1': 3 }, { '0:2': 2 } ] means the top-level list (id=1) has 3 items,
// and the nested list (id=0) has 2 items so the next continuation should start at 3.
const encounteredLists: Array<Record<string, number>> = [];

const stack: ListStack = [];

Expand All @@ -62,37 +68,51 @@ export function transformListItemLikeElementsIntoLists(
if ( !isListContinuation( itemLikeElement ) ) {
applyIndentationToTopLevelList( writer, stack, topLevelListInfo );
topLevelListInfo = createTopLevelListInfo();
// Clear counters for nested levels only. The top-level counter (index 0) must survive
// so that a resumed top-level list (same id, interrupted by a paragraph) can still
// receive the correct `start` attribute. Nested counters must be cleared because
// a sibling top-level list item should not inherit the nested list counts from
// a previous top-level list item.
encounteredLists.length = 1;
stack.length = 0;
}

// Combined list ID for addressing encounter lists counters.
// Key used to look up this list inside `encounteredLists[indent]`.
// Combines the list id and level so that two different lists at the same indent
// level (e.g. first an <ol>, then a <ul> after a paragraph break) don't share a counter.
const originalListId = `${ itemLikeElement.id }:${ itemLikeElement.indent }`;

// Normalized list item indentation.
const indent = Math.min( itemLikeElement.indent - 1, stack.length );

// Trimming of the list stack on list ID change.
if ( indent < stack.length && stack[ indent ].id !== itemLikeElement.id ) {
// A different list started at this indent level β€” counters for this level and deeper
// belong to the previous list context and must not carry over.
encounteredLists.length = indent;
stack.length = indent;
}

// Trimming of the list stack on lower indent list encountered.
if ( indent < stack.length - 1 ) {
// We jumped back to a shallower indent β€” any counters deeper than the new top are stale.
encounteredLists.length = indent + 1;
stack.length = indent + 1;
}
else {
const listStyle = detectListStyle( itemLikeElement, stylesString );

// Create a new OL/UL if required (greater indent or different list type).
if ( indent > stack.length - 1 || stack[ indent ].listElement.name != listStyle.type ) {
// Check if there is some start index to set from a previous list.
// If this list was seen before at this indent (i.e. it was interrupted by a non-list block
// and is now resuming), set `start` so the numbering continues from where it left off.
if (
indent == 0 &&
listStyle.type == 'ol' &&
itemLikeElement.id !== undefined &&
encounteredLists[ originalListId ]
encounteredLists[ indent ] &&
encounteredLists[ indent ][ originalListId ]
) {
listStyle.startIndex = encounteredLists[ originalListId ];
listStyle.startIndex = encounteredLists[ indent ][ originalListId ];
}

const listElement = createNewEmptyList( listStyle, writer, hasMultiLevelListPlugin );
Expand All @@ -116,9 +136,15 @@ export function transformListItemLikeElementsIntoLists(
listItemElements: []
};

// Prepare list counter for start index.
if ( indent == 0 && itemLikeElement.id !== undefined ) {
encounteredLists[ originalListId ] = listStyle.startIndex || 1;
// Record the starting value for this list so that if it is interrupted and resumed later,
// the continuation list can pick up numbering from the right value.
// For a fresh list `listStyle.startIndex` is undefined, so we fall back to 1.
if ( itemLikeElement.id !== undefined ) {
if ( !encounteredLists[ indent ] ) {
encounteredLists[ indent ] = {};
}

encounteredLists[ indent ][ originalListId ] = listStyle.startIndex || 1;
}
}
}
Expand All @@ -133,9 +159,9 @@ export function transformListItemLikeElementsIntoLists(
writer.appendChild( listItem, stack[ indent ].listElement );
stack[ indent ].listItemElements.push( listItem );

// Increment list counter.
if ( indent == 0 && itemLikeElement.id !== undefined ) {
encounteredLists[ originalListId ]++;
// Count the item so that `encounteredLists` always holds the value the *next* continuation list should start at.
if ( itemLikeElement.id !== undefined && encounteredLists[ indent ] ) {
encounteredLists[ indent ][ originalListId ]++;
}

// Append list block to LI.
Expand All @@ -152,13 +178,24 @@ export function transformListItemLikeElementsIntoLists(
// Other blocks in a list item.
const stackItem = stack.find( stackItem => stackItem.marginLeft == itemLikeElement.marginLeft );

// This might be a paragraph that has known margin, but it is not a real list block.
// A non-list block (e.g. a plain paragraph) whose margin-left matches one of the active list items.
// The match is done by margin-left value β€” nested list items sometimes have no explicit margin-left,
// so the match typically resolves to an ancestor <li> rather than the deepest one.
if ( stackItem ) {
const listItems = stackItem.listItemElements;

// Append block to LI.
writer.appendChild( itemLikeElement.element, listItems[ listItems.length - 1 ] );
writer.removeStyle( 'margin-left', itemLikeElement.element );

// Trim the stack to the matched level. Without this, the next nested list item would
// be appended to the existing nested <ol>/<ul> that appears *before* this paragraph
// in the DOM, instead of creating a new one *after* it.
stack.length = stack.indexOf( stackItem ) + 1;
// Clear counters only for levels deeper than the direct children of the matched <li>.
// The counter at `stack.length` must survive so the next nested list can continue
// numbering from where it left off (e.g. <ol start="3">).
encounteredLists.length = stack.length + 1;
} else {
stack.length = 0;
}
Expand Down
12 changes: 9 additions & 3 deletions packages/ckeditor5-paste-from-office/tests/_data/list/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import mixedElements from './mixed-elements/input.word.html';
import multiBlockBlockAfter from './multi-block-block-after/input.word.html';
import listContinuation from './list-continuation/input.word2016.html';
import indentBlockList from './indent-block-list/input.word.html';
import nestedContinued from './nested-continued/input.word.html';

import simpleNormalized from './simple/normalized.word2016.html';
import styledNormalized from './styled/normalized.word2016.html';
Expand All @@ -43,6 +44,7 @@ import mixedElementsNormalized from './mixed-elements/normalized.word.html';
import multiBlockBlockAfterNormalized from './multi-block-block-after/normalized.word.html';
import listContinuationNormalized from './list-continuation/normalized.word2016.html';
import indentBlockListNormalized from './indent-block-list/normalized.word.html';
import nestedContinuedNormalized from './nested-continued/normalized.word.html';

import simpleModel from './simple/model.word2016.html';
import styledModel from './styled/model.word2016.html';
Expand All @@ -63,6 +65,7 @@ import mixedElementsModel from './mixed-elements/model.word.html';
import multiBlockBlockAfterModel from './multi-block-block-after/model.word.html';
import listContinuationModel from './list-continuation/model.word2016.html';
import indentBlockListModel from './indent-block-list/model.word.html';
import nestedContinuedModel from './nested-continued/model.word.html';

export const fixtures = {
input: {
Expand All @@ -84,7 +87,8 @@ export const fixtures = {
mixedElements,
multiBlockBlockAfter,
listContinuation,
indentBlockList
indentBlockList,
nestedContinued
},
normalized: {
simple: simpleNormalized,
Expand All @@ -105,7 +109,8 @@ export const fixtures = {
mixedElements: mixedElementsNormalized,
multiBlockBlockAfter: multiBlockBlockAfterNormalized,
listContinuation: listContinuationNormalized,
indentBlockList: indentBlockListNormalized
indentBlockList: indentBlockListNormalized,
nestedContinued: nestedContinuedNormalized
},
model: {
simple: simpleModel,
Expand All @@ -126,7 +131,8 @@ export const fixtures = {
mixedElements: mixedElementsModel,
multiBlockBlockAfter: multiBlockBlockAfterModel,
listContinuation: listContinuationModel,
indentBlockList: indentBlockListModel
indentBlockList: indentBlockListModel,
nestedContinued: nestedContinuedModel
}
};

Expand Down
Binary file not shown.
Loading