diff --git a/.changelog/20260323163157_ck_19975_marker_boundary_order.md b/.changelog/20260323163157_ck_19975_marker_boundary_order.md new file mode 100644 index 00000000000..46c2a1eed05 --- /dev/null +++ b/.changelog/20260323163157_ck_19975_marker_boundary_order.md @@ -0,0 +1,11 @@ +--- +type: Fix +scope: + - ckeditor5-engine +closes: + - 19975 +--- + +Fixed the editing downcast order of adjacent marker UI boundaries so marker ends and starts are rendered consistently with the model and data output. + +The editing pipeline now produces a deterministic marker order and preserves the expected boundary order when adjacent markers are added together or when the second adjacent marker is added later. diff --git a/packages/ckeditor5-engine/src/controller/datacontroller.ts b/packages/ckeditor5-engine/src/controller/datacontroller.ts index 26e6a0565a7..d98efce848c 100644 --- a/packages/ckeditor5-engine/src/controller/datacontroller.ts +++ b/packages/ckeditor5-engine/src/controller/datacontroller.ts @@ -669,45 +669,5 @@ function _getMarkersRelativeToElement( element: ModelElement ): Map { - 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 ); } diff --git a/packages/ckeditor5-engine/src/conversion/comparemarkers.ts b/packages/ckeditor5-engine/src/conversion/comparemarkers.ts new file mode 100644 index 00000000000..6ea475b4a6e --- /dev/null +++ b/packages/ckeditor5-engine/src/conversion/comparemarkers.ts @@ -0,0 +1,81 @@ +/** + * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +/** + * @module engine/conversion/comparemarkers + */ + +import type { ModelRange } from '../model/range.js'; + +/** + * Sorts markers so the downcast result is deterministic regardless of the order + * markers were added to the marker collection. + * + * The sort key is the marker's range, ordered "right-to-left" through the document so that + * a marker's opening boundary is processed *after* any markers nested inside it. This way + * the outer marker wraps the inner ones at conversion time. + * + * Cases (positions shown as `0123456789`, sort result top-to-bottom): + * + * 1. Non-overlapping ranges — sorted by position, last range first: + * + * a: [--] → c, b, a + * b: [--] + * c: [--] + * + * 2. Adjacent ranges (end === start) — treated as non-overlapping: + * + * first: [---] → third, second, first + * second: [---] + * third: [---] + * + * 3. Nested ranges (same start, different ends) — inner first, outer last: + * + * shorter: [-] → shorter, longer + * longer: [---] + * + * 4. Partially overlapping ranges — sorted by start position: + * + * earlier: [---] → later, earlier + * later: [---] + * + * 5. Identical ranges — fall back to reverse name comparison: + * + * alpha: [---] → charlie, bravo, alpha + * bravo: [---] + * charlie: [---] + * + * @internal + */ +export function compareMarkersForDowncast( + [ name1, range1 ]: readonly [ string, ModelRange ], + [ name2, range2 ]: readonly [ string, ModelRange ] +): number { + if ( range1.end.compareWith( range2.start ) !== 'after' ) { + // m1.end <= m2.start -- m1 is entirely <= m2. + return 1; + } else if ( range1.start.compareWith( range2.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 ( range1.start.compareWith( range2.start ) ) { + case 'before': + return 1; + case 'after': + return -1; + default: + switch ( range1.end.compareWith( range2.end ) ) { + case 'before': + return -1; + case 'after': + return 1; + default: + return name2.localeCompare( name1 ); + } + } + } +} diff --git a/packages/ckeditor5-engine/src/conversion/downcastdispatcher.ts b/packages/ckeditor5-engine/src/conversion/downcastdispatcher.ts index db36dfe6c75..7145ef54549 100644 --- a/packages/ckeditor5-engine/src/conversion/downcastdispatcher.ts +++ b/packages/ckeditor5-engine/src/conversion/downcastdispatcher.ts @@ -8,6 +8,7 @@ */ import { ModelConsumable } from './modelconsumable.js'; +import { compareMarkersForDowncast } from './comparemarkers.js'; import { ModelRange } from '../model/range.js'; import { EmitterMixin } from '@ckeditor/ckeditor5-utils'; @@ -200,8 +201,24 @@ export class DowncastDispatcher extends /* #__PURE__ */ EmitterMixin() { this._convertMarkerAdd( markerName, markerRange, conversionApi ); } + // Sort markers so the downcast result is deterministic regardless of the order + // markers were added to the collection. + // + // "Reverse DOM order" = markers ending later in the document come first, so each + // marker's opening boundary is processed after any markers nested inside it. + // For overlapping ranges this is best-effort (start position wins, then end position). + // + // 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: + // + // Sorted (reverse DOM order): oldnew + // Insertion order (legacy): oldnew + 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 differ.getMarkersToAdd() ) { + for ( const change of markersToAdd ) { this._convertMarkerAdd( change.name, change.range, conversionApi ); } @@ -230,7 +247,9 @@ export class DowncastDispatcher extends /* #__PURE__ */ EmitterMixin() { this._convertInsert( range, conversionApi ); - for ( const [ name, range ] of markers ) { + // 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 ) ) { this._convertMarkerAdd( name, range, conversionApi ); } diff --git a/packages/ckeditor5-engine/src/conversion/downcasthelpers.ts b/packages/ckeditor5-engine/src/conversion/downcasthelpers.ts index 1249a9af667..f1df5b1fc8f 100644 --- a/packages/ckeditor5-engine/src/conversion/downcasthelpers.ts +++ b/packages/ckeditor5-engine/src/conversion/downcasthelpers.ts @@ -1335,16 +1335,24 @@ export function insertUIElement( elementCreator: DowncastMarkerElementCreatorFun const mapper = conversionApi.mapper; const viewWriter = conversionApi.writer; - // Add "opening" element. - viewWriter.insert( mapper.toViewPosition( markerRange.start ), viewStartElement ); - conversionApi.mapper.bindElementToMarker( viewStartElement, data.markerName ); + viewWriter.setCustomProperty( 'markerBoundaryType', 'start', viewStartElement ); + viewWriter.setCustomProperty( 'markerBoundaryType', 'end', viewEndElement ); - // Add "closing" element only if range is not collapsed. + // Add "end" 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(); }; } diff --git a/packages/ckeditor5-engine/tests/conversion/comparemarkers.js b/packages/ckeditor5-engine/tests/conversion/comparemarkers.js new file mode 100644 index 00000000000..2f3a1b3beca --- /dev/null +++ b/packages/ckeditor5-engine/tests/conversion/comparemarkers.js @@ -0,0 +1,160 @@ +/** + * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +import { Model } from '../../src/model/model.js'; +import { ModelText } from '../../src/model/text.js'; +import { compareMarkersForDowncast } from '../../src/conversion/comparemarkers.js'; + +describe( 'compareMarkersForDowncast()', () => { + let model, root; + + beforeEach( () => { + model = new Model(); + root = model.document.createRoot(); + + root._appendChild( [ + new ModelText( 'abcdefghij' ) + ] ); + } ); + + function range( startOffset, endOffset ) { + return model.createRange( + model.createPositionFromPath( root, [ startOffset ] ), + model.createPositionFromPath( root, [ endOffset ] ) + ); + } + + function sortedNames( markers ) { + return markers.sort( compareMarkersForDowncast ).map( ( [ name ] ) => name ); + } + + describe( 'non-overlapping ranges', () => { + it( 'should sort in reverse DOM order', () => { + expect( sortedNames( [ + [ 'a', range( 0, 2 ) ], + [ 'b', range( 4, 6 ) ], + [ 'c', range( 7, 9 ) ] + ] ) ).to.deep.equal( [ 'c', 'b', 'a' ] ); + } ); + + it( 'should sort in reverse DOM order regardless of initial order', () => { + expect( sortedNames( [ + [ 'c', range( 7, 9 ) ], + [ 'a', range( 0, 2 ) ], + [ 'b', range( 4, 6 ) ] + ] ) ).to.deep.equal( [ 'c', 'b', 'a' ] ); + } ); + + it( 'should treat adjacent ranges (end == start) as non-overlapping', () => { + expect( sortedNames( [ + [ 'first', range( 0, 3 ) ], + [ 'second', range( 3, 6 ) ], + [ 'third', range( 6, 9 ) ] + ] ) ).to.deep.equal( [ 'third', 'second', 'first' ] ); + } ); + } ); + + describe( 'overlapping ranges', () => { + it( 'should sort outer marker after inner marker (outer starts earlier)', () => { + expect( sortedNames( [ + [ 'inner', range( 3, 5 ) ], + [ 'outer', range( 1, 7 ) ] + ] ) ).to.deep.equal( [ 'inner', 'outer' ] ); + } ); + + it( 'should sort by start position first for partially overlapping ranges', () => { + expect( sortedNames( [ + [ 'earlier', range( 1, 5 ) ], + [ 'later', range( 3, 7 ) ] + ] ) ).to.deep.equal( [ 'later', 'earlier' ] ); + } ); + + it( 'should use end position as secondary key when starts are equal', () => { + // Same start — the shorter range (ending earlier) sorts first so that the + // longer (outer) marker is processed last and its opening tag wraps the inner one. + expect( sortedNames( [ + [ 'shorter', range( 2, 4 ) ], + [ 'longer', range( 2, 6 ) ] + ] ) ).to.deep.equal( [ 'shorter', 'longer' ] ); + } ); + + it( 'should sort three nested markers from innermost to outermost', () => { + expect( sortedNames( [ + [ 'outer', range( 0, 9 ) ], + [ 'mid', range( 2, 7 ) ], + [ 'inner', range( 4, 5 ) ] + ] ) ).to.deep.equal( [ 'inner', 'mid', 'outer' ] ); + } ); + + it( 'should sort three nested markers from innermost to outermost regardless of initial order', () => { + expect( sortedNames( [ + [ 'inner', range( 4, 5 ) ], + [ 'outer', range( 0, 9 ) ], + [ 'mid', range( 2, 7 ) ] + ] ) ).to.deep.equal( [ 'inner', 'mid', 'outer' ] ); + } ); + } ); + + describe( 'identical ranges', () => { + it( 'should fall back to reverse name comparison for identical ranges', () => { + expect( sortedNames( [ + [ 'alpha', range( 2, 5 ) ], + [ 'charlie', range( 2, 5 ) ], + [ 'bravo', range( 2, 5 ) ] + ] ) ).to.deep.equal( [ 'charlie', 'bravo', 'alpha' ] ); + } ); + + it( 'should preserve order for markers with identical ranges and names', () => { + const markers = [ + [ 'same', range( 2, 5 ) ], + [ 'same', range( 2, 5 ) ] + ]; + + const result = compareMarkersForDowncast( markers[ 0 ], markers[ 1 ] ); + + expect( result ).to.equal( 0 ); + } ); + } ); + + describe( 'mixed scenarios', () => { + it( 'should correctly sort a mix of non-overlapping and overlapping ranges', () => { + expect( sortedNames( [ + [ 'solo', range( 8, 9 ) ], + [ 'outer', range( 0, 6 ) ], + [ 'inner', range( 2, 4 ) ] + ] ) ).to.deep.equal( [ 'solo', 'inner', 'outer' ] ); + } ); + + it( 'should correctly sort overlapping ranges sharing the same start with a non-overlapping range', () => { + expect( sortedNames( [ + [ 'short', range( 0, 3 ) ], + [ 'long', range( 0, 7 ) ], + [ 'separate', range( 8, 9 ) ] + ] ) ).to.deep.equal( [ 'separate', 'short', 'long' ] ); + } ); + + it( 'should sort many markers consistently regardless of initial order', () => { + const expected = [ 'e', 'd', 'c', 'b', 'a' ]; + + // Reversed initial order. + expect( sortedNames( [ + [ 'a', range( 0, 2 ) ], + [ 'b', range( 2, 4 ) ], + [ 'c', range( 4, 6 ) ], + [ 'd', range( 6, 8 ) ], + [ 'e', range( 8, 10 ) ] + ] ) ).to.deep.equal( expected ); + + // Random initial order. + expect( sortedNames( [ + [ 'c', range( 4, 6 ) ], + [ 'e', range( 8, 10 ) ], + [ 'a', range( 0, 2 ) ], + [ 'd', range( 6, 8 ) ], + [ 'b', range( 2, 4 ) ] + ] ) ).to.deep.equal( expected ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-engine/tests/conversion/downcasthelpers.js b/packages/ckeditor5-engine/tests/conversion/downcasthelpers.js index d17594de76b..e02cbae95a9 100644 --- a/packages/ckeditor5-engine/tests/conversion/downcasthelpers.js +++ b/packages/ckeditor5-engine/tests/conversion/downcasthelpers.js @@ -424,15 +424,15 @@ describe( 'DowncastHelpers', () => { it( 'should properly re-bind mapper mappings and retain markers', () => { downcastHelpers.elementToElement( { - model: 'simpleBlock', + model: { + name: 'simpleBlock', + attributes: [ 'toStyle', 'toClass' ] + }, view: ( modelElement, { writer } ) => { const viewElement = writer.createContainerElement( 'div', getViewAttributes( modelElement ) ); return toWidget( viewElement, writer ); }, - triggerBy: { - attributes: [ 'toStyle', 'toClass' ] - }, converterPriority: 'high' } ); @@ -1287,15 +1287,15 @@ describe( 'DowncastHelpers', () => { it( 'should properly re-bind mapper mappings and retain markers', () => { downcastHelpers.elementToElement( { - model: 'simpleBlock', + model: { + name: 'simpleBlock', + attributes: [ 'toStyle', 'toClass' ] + }, view: ( modelElement, { writer } ) => { const viewElement = writer.createContainerElement( 'div', getViewAttributes( modelElement ) ); return toWidget( viewElement, writer ); }, - triggerBy: { - attributes: [ 'toStyle', 'toClass' ] - }, converterPriority: 'high' } ); @@ -4162,6 +4162,431 @@ describe( 'DowncastHelpers', () => { expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); + it( 'should keep adjacent marker boundaries in model order when markers are added together', () => { + downcastHelpers.markerToElement( { + model: 'marker', + view: ( data, { writer } ) => { + return writer.createUIElement( 'span', { + class: `${ data.markerName }-${ data.isOpening ? 'start' : 'end' }` + } ); + } + } ); + + model.change( writer => { + writer.addMarker( 'marker:2', { + range: writer.createRange( writer.createPositionAt( modelElement, 3 ), writer.createPositionAt( modelElement, 4 ) ), + usingOperation: false + } ); + writer.addMarker( 'marker:1', { + range: writer.createRange( writer.createPositionAt( modelElement, 2 ), writer.createPositionAt( modelElement, 3 ) ), + usingOperation: false + } ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( + '

fo' + + 'ob' + + 'ar

' + ); + } ); + + it( 'should keep adjacent marker boundaries in model order when the second marker is added later', () => { + downcastHelpers.markerToElement( { + model: 'marker', + view: ( data, { writer } ) => { + return writer.createUIElement( 'span', { + class: `${ data.markerName }-${ data.isOpening ? 'start' : 'end' }` + } ); + } + } ); + + model.change( writer => { + writer.addMarker( 'marker:1', { + range: writer.createRange( writer.createPositionAt( modelElement, 2 ), writer.createPositionAt( modelElement, 3 ) ), + usingOperation: false + } ); + } ); + + model.change( writer => { + writer.addMarker( 'marker:2', { + range: writer.createRange( writer.createPositionAt( modelElement, 3 ), writer.createPositionAt( modelElement, 4 ) ), + usingOperation: false + } ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( + '

fo' + + 'ob' + + 'ar

' + ); + } ); + + it( 'adjacent markers do not overlap regardless of creation order', () => { + downcastHelpers.markerToElement( { + model: 'marker', + view: ( data, { writer } ) => { + return writer.createUIElement( 'span', { + class: `${ data.markerName }-${ data.isOpening ? 'start' : 'end' }` + } ); + } + } ); + + const rangeA = model.createRange( model.createPositionAt( modelElement, 0 ), model.createPositionAt( modelElement, 3 ) ); + const rangeB = model.createRange( model.createPositionAt( modelElement, 3 ), model.createPositionAt( modelElement, 6 ) ); + + model.change( writer => { + writer.addMarker( 'marker:a', { range: rangeA, usingOperation: false } ); + writer.addMarker( 'marker:b', { range: rangeB, usingOperation: false } ); + } ); + + const expected = + '

' + + 'foo' + + 'bar' + + '

'; + + expect( viewToString( viewRoot ) ).to.equal( expected ); + + // Remove all markers. + model.change( writer => { + writer.removeMarker( 'marker:a' ); + writer.removeMarker( 'marker:b' ); + } ); + + // Re-add in reversed order in a fresh change block so the differ + // processes them in the new order. + model.change( writer => { + writer.addMarker( 'marker:b', { range: rangeB, usingOperation: false } ); + writer.addMarker( 'marker:a', { range: rangeA, usingOperation: false } ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( expected ); + } ); + + it( 'nested markers sharing the same start position preserve outer-first nesting order', () => { + downcastHelpers.markerToElement( { + model: 'marker', + view: ( data, { writer } ) => { + return writer.createUIElement( 'span', { + class: `${ data.markerName }-${ data.isOpening ? 'start' : 'end' }` + } ); + } + } ); + + // "foobar" — marker:outer spans [0,6], marker:inner spans [0,3]. + // Both share the same start position but differ in end position. + const outerRange = model.createRange( + model.createPositionAt( modelElement, 0 ), + model.createPositionAt( modelElement, 6 ) + ); + const innerRange = model.createRange( + model.createPositionAt( modelElement, 0 ), + model.createPositionAt( modelElement, 3 ) + ); + + model.change( writer => { + writer.addMarker( 'marker:outer', { range: outerRange, usingOperation: false } ); + writer.addMarker( 'marker:inner', { range: innerRange, usingOperation: false } ); + } ); + + const expected = + '

' + + '' + + '' + + 'foo' + + '' + + 'bar' + + '' + + '

'; + + expect( viewToString( viewRoot ) ).to.equal( expected ); + + // Remove all markers. + model.change( writer => { + writer.removeMarker( 'marker:outer' ); + writer.removeMarker( 'marker:inner' ); + } ); + + // Re-add in reversed order to verify stability. + model.change( writer => { + writer.addMarker( 'marker:inner', { range: innerRange, usingOperation: false } ); + writer.addMarker( 'marker:outer', { range: outerRange, usingOperation: false } ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( expected ); + } ); + + it( 'intersecting markers downcast consistently regardless of creation order', () => { + downcastHelpers.markerToElement( { + model: 'marker', + view: ( data, { writer } ) => { + return writer.createUIElement( 'span', { + class: `${ data.markerName }-${ data.isOpening ? 'start' : 'end' }` + } ); + } + } ); + + function r( start, end ) { + return model.createRange( model.createPositionAt( modelElement, start ), model.createPositionAt( modelElement, end ) ); + } + + // "foobar" — offsets 0..6 + const markerRanges = { + base: r( 1, 5 ), + equal: r( 1, 5 ), + outsideStart: r( 0, 1 ), + overlapStart: r( 0, 2 ), + insideStart: r( 1, 3 ), + inside: r( 2, 4 ), + insideEnd: r( 4, 5 ), + overlapEnd: r( 4, 6 ), + outsideEnd: r( 5, 6 ) + }; + + model.change( writer => { + for ( const [ name, range ] of Object.entries( markerRanges ) ) { + writer.addMarker( `marker:${ name }`, { range, usingOperation: false } ); + } + } ); + + const result = viewToString( viewRoot ); + + // Remove all markers. + model.change( writer => { + for ( const name of Object.keys( markerRanges ) ) { + writer.removeMarker( `marker:${ name }` ); + } + } ); + + // Re-add in reversed order in a fresh change block so the differ + // processes them in the new order. + model.change( writer => { + for ( const [ name, range ] of Object.entries( markerRanges ).reverse() ) { + writer.addMarker( `marker:${ name }`, { range, usingOperation: false } ); + } + } ); + + expect( viewToString( viewRoot ) ).to.equal( result ); + } ); + + it( 'should not change adjacent marker positions when text spanning the boundary is wrapped with bold', () => { + downcastHelpers.markerToElement( { + model: 'marker', + view: ( data, { writer } ) => { + return writer.createUIElement( 'span', { + class: `${ data.markerName }-${ data.isOpening ? 'start' : 'end' }` + } ); + } + } ); + + downcastHelpers.attributeToElement( { model: 'bold', view: 'strong' } ); + model.schema.extend( '$text', { allowAttributes: 'bold' } ); + + model.change( writer => { + writer.addMarker( 'marker:1', { + range: writer.createRange( writer.createPositionAt( modelElement, 2 ), writer.createPositionAt( modelElement, 3 ) ), + usingOperation: false + } ); + writer.addMarker( 'marker:2', { + range: writer.createRange( writer.createPositionAt( modelElement, 3 ), writer.createPositionAt( modelElement, 4 ) ), + usingOperation: false + } ); + } ); + + // Wrap text spanning the marker boundary (positions 1-5) with bold. + model.change( writer => { + writer.setAttribute( + 'bold', true, + writer.createRange( writer.createPositionAt( modelElement, 1 ), writer.createPositionAt( modelElement, 5 ) ) + ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( + '

fo' + + 'ob' + + 'ar

' + ); + } ); + + it( 'should not change adjacent marker positions when bold is removed from text spanning the boundary', () => { + downcastHelpers.markerToElement( { + model: 'marker', + view: ( data, { writer } ) => { + return writer.createUIElement( 'span', { + class: `${ data.markerName }-${ data.isOpening ? 'start' : 'end' }` + } ); + } + } ); + + downcastHelpers.attributeToElement( { model: 'bold', view: 'strong' } ); + model.schema.extend( '$text', { allowAttributes: 'bold' } ); + + // Insert bold text and markers. + model.change( writer => { + writer.setAttribute( + 'bold', true, + writer.createRange( writer.createPositionAt( modelElement, 1 ), writer.createPositionAt( modelElement, 5 ) ) + ); + + writer.addMarker( 'marker:1', { + range: writer.createRange( writer.createPositionAt( modelElement, 2 ), writer.createPositionAt( modelElement, 3 ) ), + usingOperation: false + } ); + writer.addMarker( 'marker:2', { + range: writer.createRange( writer.createPositionAt( modelElement, 3 ), writer.createPositionAt( modelElement, 4 ) ), + usingOperation: false + } ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( + '

fo' + + 'ob' + + 'ar

' + ); + + // Remove bold from the same range. + model.change( writer => { + writer.removeAttribute( + 'bold', + writer.createRange( writer.createPositionAt( modelElement, 1 ), writer.createPositionAt( modelElement, 5 ) ) + ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( + '

fo' + + 'ob' + + 'ar

' + ); + } ); + + it( 'should keep adjacent marker boundary order when one marker is inside bold and the other is not', () => { + downcastHelpers.markerToElement( { + model: 'marker', + view: ( data, { writer } ) => { + return writer.createUIElement( 'span', { + class: `${ data.markerName }-${ data.isOpening ? 'start' : 'end' }` + } ); + } + } ); + + downcastHelpers.attributeToElement( { model: 'bold', view: 'strong' } ); + model.schema.extend( '$text', { allowAttributes: 'bold' } ); + + // Make "foo" bold: <$text bold>foobar + model.change( writer => { + writer.setAttribute( + 'bold', true, + writer.createRange( writer.createPositionAt( modelElement, 0 ), writer.createPositionAt( modelElement, 3 ) ) + ); + } ); + + // marker:1 at [1, 3) — inside the bold text. + // marker:2 at [3, 5) — outside the bold text. + // Adjacent at offset 3 (the bold boundary). + model.change( writer => { + writer.addMarker( 'marker:1', { + range: writer.createRange( writer.createPositionAt( modelElement, 1 ), writer.createPositionAt( modelElement, 3 ) ), + usingOperation: false + } ); + writer.addMarker( 'marker:2', { + range: writer.createRange( writer.createPositionAt( modelElement, 3 ), writer.createPositionAt( modelElement, 5 ) ), + usingOperation: false + } ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( + '

f' + + 'oo' + + '' + + 'bar

' + ); + } ); + + it( 'should keep adjacent marker boundary order when the second marker is inside bold and the first is not', () => { + downcastHelpers.markerToElement( { + model: 'marker', + view: ( data, { writer } ) => { + return writer.createUIElement( 'span', { + class: `${ data.markerName }-${ data.isOpening ? 'start' : 'end' }` + } ); + } + } ); + + downcastHelpers.attributeToElement( { model: 'bold', view: 'strong' } ); + model.schema.extend( '$text', { allowAttributes: 'bold' } ); + + // Make "bar" bold: foo<$text bold>bar + model.change( writer => { + writer.setAttribute( + 'bold', true, + writer.createRange( writer.createPositionAt( modelElement, 3 ), writer.createPositionAt( modelElement, 6 ) ) + ); + } ); + + // marker:1 at [1, 3) — outside the bold text. + // marker:2 at [3, 5) — inside the bold text. + // Adjacent at offset 3 (the bold boundary). + model.change( writer => { + writer.addMarker( 'marker:1', { + range: writer.createRange( writer.createPositionAt( modelElement, 1 ), writer.createPositionAt( modelElement, 3 ) ), + usingOperation: false + } ); + writer.addMarker( 'marker:2', { + range: writer.createRange( writer.createPositionAt( modelElement, 3 ), writer.createPositionAt( modelElement, 5 ) ), + usingOperation: false + } ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( + '

f' + + 'oo' + + '' + + 'bar

' + ); + } ); + + it( 'should preserve adjacent marker positions when container element is renamed', () => { + model.schema.register( 'heading1', { inheritAllFrom: '$block' } ); + downcastHelpers.elementToElement( { model: 'heading1', view: 'h1' } ); + + downcastHelpers.markerToElement( { + model: 'marker', + view: ( data, { writer } ) => { + return writer.createUIElement( 'span', { + class: `${ data.markerName }-${ data.isOpening ? 'start' : 'end' }` + } ); + } + } ); + + model.change( writer => { + writer.addMarker( 'marker:1', { + range: writer.createRange( writer.createPositionAt( modelElement, 2 ), writer.createPositionAt( modelElement, 3 ) ), + usingOperation: false + } ); + writer.addMarker( 'marker:2', { + range: writer.createRange( writer.createPositionAt( modelElement, 3 ), writer.createPositionAt( modelElement, 4 ) ), + usingOperation: false + } ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( + '

fo' + + 'ob' + + 'ar

' + ); + + // Rename paragraph to heading1 — triggers reconversion of the container element. + model.change( writer => { + writer.rename( modelElement, 'heading1' ); + } ); + + expect( viewToString( viewRoot ) ).to.equal( + '

fo' + + 'ob' + + 'ar

' + ); + } ); + it( 'should not convert if consumable was consumed', () => { sinon.spy( controller.downcastDispatcher, 'fire' ); diff --git a/packages/ckeditor5-list/tests/legacytodolist/legacytodolistediting.js b/packages/ckeditor5-list/tests/legacytodolist/legacytodolistediting.js index b5e4375d64f..cb8b20a3bea 100644 --- a/packages/ckeditor5-list/tests/legacytodolist/legacytodolistediting.js +++ b/packages/ckeditor5-list/tests/legacytodolist/legacytodolistediting.js @@ -603,11 +603,9 @@ describe( 'LegacyTodoListEditing', () => { '
  • ' + '' + '' + - '[]' + - '' + - '' + - 'foo' + - '' + + '[]' + + '' + + 'foo' + '' + '
  • ' + '
  • ' +