-
-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Fix inconsistent marker boundary order #19995
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
arkflpc
wants to merge
7
commits into
master
Choose a base branch
from
ck/19975-marker-boundary-order
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 5 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
3a1b20b
Fix inconsistent marker boundary order
arkflpc cf2a7d5
Changelog message.
arkflpc a643568
Removed leftover comment.
arkflpc 5829a2a
Adjusted list tests.
arkflpc 18d11c3
Removed double markers sorting in data pipeline downcast. Code cleanuโฆ
niegowski f8adbee
Replaced "opening", "closing" custom property values with "start", "eโฆ
niegowski 522621c
Updated code comment.
niegowski File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
11 changes: 11 additions & 0 deletions
11
.changelog/20260323163157_ck_19975_marker_boundary_order.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
49 changes: 49 additions & 0 deletions
49
packages/ckeditor5-engine/src/conversion/comparemarkers.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ); | ||
| } | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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', 'opening', viewStartElement ); | ||
| viewWriter.setCustomProperty( 'markerBoundaryType', 'closing', viewEndElement ); | ||
|
||
|
|
||
| // 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 closing UI elements to find a proper position for "opening" element. | ||
| // It should be after all marker closing 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' ) === 'closing' | ||
| ); | ||
|
|
||
| viewWriter.insert( startViewPosition, viewStartElement ); | ||
| conversionApi.mapper.bindElementToMarker( viewStartElement, data.markerName ); | ||
|
|
||
| evt.stop(); | ||
| }; | ||
| } | ||
|
|
||
159 changes: 159 additions & 0 deletions
159
packages/ckeditor5-engine/tests/conversion/comparemarkers.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ); | ||
| } ); | ||
| } ); | ||
| } ); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I propose to add an example, and maybe shorten the text then. It will be easier to imagine what is going on here.