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..2cd2c7d389c --- /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 uses stable marker ordering 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..ab01904fb52 --- /dev/null +++ b/packages/ckeditor5-engine/src/conversion/comparemarkers.ts @@ -0,0 +1,49 @@ +/** + * @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 in a stable fashion so their addition order does not affect downcast output. + * + * Markers are ordered in reverse DOM order for non-intersecting ranges. For intersecting ranges, + * the start position is the primary sort key and the end position is the secondary sort key. + * + * @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..74d0ed22ae1 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,23 @@ 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): oldnew + // Unstable (insertion order): oldnew + // + // 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 differ.getMarkersToAdd() ) { + for ( const change of markersToAdd ) { this._convertMarkerAdd( change.name, change.range, conversionApi ); } @@ -230,7 +246,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..20c3ceab4f6 --- /dev/null +++ b/packages/ckeditor5-engine/tests/conversion/comparemarkers.js @@ -0,0 +1,159 @@ +/** + * @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 longer range (ending later) sorts first, shorter after. + expect( sortedNames( [ + [ 'shorter', range( 2, 4 ) ], + [ 'longer', range( 2, 6 ) ] + ] ) ).to.deep.equal( [ 'longer', 'shorter' ] ); + } ); + + 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', 'long', 'short' ] ); + } ); + + 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..21958e24866 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,378 @@ 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( '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' + '' + '
  • ' + '
  • ' +