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
11 changes: 0 additions & 11 deletions .changelog/20260323163157_ck_19975_marker_boundary_order.md

This file was deleted.

40 changes: 40 additions & 0 deletions packages/ckeditor5-engine/src/controller/datacontroller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -669,5 +669,45 @@ function _getMarkersRelativeToElement( element: ModelElement ): Map<string, Mode
}
}

// Sort the markers in a stable fashion to ensure that the order in which they are
// added to the model's marker collection does not affect how they are
// downcast. One particular use case that we are targeting here, is one where
// two markers are adjacent but not overlapping, such as an insertion/deletion
// suggestion pair representing the replacement of a range of text. In this
// case, putting the markers in DOM order causes the first marker's end to be
// serialized right after the second marker's start, while putting the markers
// in reverse DOM order causes it to be right before the second marker's
// start. So, we sort these in a way that ensures non-intersecting ranges are in
// reverse DOM order, and intersecting ranges are in something approximating
// reverse DOM order (since reverse DOM order doesn't have a precise meaning
// when working with intersecting ranges).
result.sort( ( [ n1, r1 ], [ n2, r2 ] ) => {
if ( r1.end.compareWith( r2.start ) !== 'after' ) {
// m1.end <= m2.start -- m1 is entirely <= m2
return 1;
} else if ( r1.start.compareWith( r2.end ) !== 'before' ) {
// m1.start >= m2.end -- m1 is entirely >= m2
return -1;
} else {
// they overlap, so use their start positions as the primary sort key and
// end positions as the secondary sort key
switch ( r1.start.compareWith( r2.start ) ) {
case 'before':
return 1;
case 'after':
return -1;
default:
switch ( r1.end.compareWith( r2.end ) ) {
case 'before':
return 1;
case 'after':
return -1;
default:
return n2.localeCompare( n1 );
}
}
}
} );

return new Map( result );
}
49 changes: 0 additions & 49 deletions packages/ckeditor5-engine/src/conversion/comparemarkers.ts

This file was deleted.

22 changes: 2 additions & 20 deletions packages/ckeditor5-engine/src/conversion/downcastdispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
*/

import { ModelConsumable } from './modelconsumable.js';
import { compareMarkersForDowncast } from './comparemarkers.js';
import { ModelRange } from '../model/range.js';

import { EmitterMixin } from '@ckeditor/ckeditor5-utils';
Expand Down Expand Up @@ -201,23 +200,8 @@ export class DowncastDispatcher extends /* #__PURE__ */ EmitterMixin() {
this._convertMarkerAdd( markerName, markerRange, conversionApi );
}

// Sort markers in reverse DOM order so that the downcast result is deterministic
// regardless of the order markers were added to the collection.
//
// Example: replacing "old" with "new" creates two adjacent markers (delete + insert).
// With `markerToElement`, each boundary is a self-closing tag, so the processing
// order directly controls where they land at the shared boundary point:
//
// Stable (reverse DOM order): <DEL-START/>old<DEL-END/><INS-START/>new<INS-END/>
// Unstable (insertion order): <DEL-START/>old<INS-START/><DEL-END/>new<INS-END/>
//
// Non-intersecting ranges → strict reverse DOM order.
// Intersecting ranges → best-effort reverse DOM order (ambiguous by nature).
const markersToAdd = differ.getMarkersToAdd()
.sort( ( a, b ) => compareMarkersForDowncast( [ a.name, a.range ], [ b.name, b.range ] ) );

// After the view is updated, convert markers which have changed.
for ( const change of markersToAdd ) {
for ( const change of differ.getMarkersToAdd() ) {
this._convertMarkerAdd( change.name, change.range, conversionApi );
}

Expand Down Expand Up @@ -246,9 +230,7 @@ export class DowncastDispatcher extends /* #__PURE__ */ EmitterMixin() {

this._convertInsert( range, conversionApi );

// Sort markers in reverse DOM order for deterministic downcast output.
// See the analogous sort in `convertChanges()` for a detailed rationale and examples.
for ( const [ name, range ] of Array.from( markers ).sort( compareMarkersForDowncast ) ) {
for ( const [ name, range ] of markers ) {
this._convertMarkerAdd( name, range, conversionApi );
}

Expand Down
16 changes: 4 additions & 12 deletions packages/ckeditor5-engine/src/conversion/downcasthelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1335,24 +1335,16 @@ export function insertUIElement( elementCreator: DowncastMarkerElementCreatorFun
const mapper = conversionApi.mapper;
const viewWriter = conversionApi.writer;

viewWriter.setCustomProperty( 'markerBoundaryType', 'start', viewStartElement );
viewWriter.setCustomProperty( 'markerBoundaryType', 'end', viewEndElement );
// Add "opening" element.
viewWriter.insert( mapper.toViewPosition( markerRange.start ), viewStartElement );
conversionApi.mapper.bindElementToMarker( viewStartElement, data.markerName );

// Add "end" element only if range is not collapsed.
// Add "closing" element only if range is not collapsed.
if ( !markerRange.isCollapsed ) {
viewWriter.insert( mapper.toViewPosition( markerRange.end ), viewEndElement );
conversionApi.mapper.bindElementToMarker( viewEndElement, data.markerName );
}

// Jump over end UI elements to find a proper position for "start" element.
// It should be after all marker "end" UI elements as markers conversion should be triggered in reverse DOM order.
const startViewPosition = mapper.toViewPosition( markerRange.start ).getLastMatchingPosition( ( { item } ) =>
item.is( 'uiElement' ) && item.getCustomProperty( 'markerBoundaryType' ) === 'end'
);

viewWriter.insert( startViewPosition, viewStartElement );
conversionApi.mapper.bindElementToMarker( viewStartElement, data.markerName );

evt.stop();
};
}
Expand Down
159 changes: 0 additions & 159 deletions packages/ckeditor5-engine/tests/conversion/comparemarkers.js

This file was deleted.

Loading