Skip to content
Open
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
14 changes: 12 additions & 2 deletions packages/ckeditor5-engine/src/conversion/downcastdispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -916,9 +916,19 @@ function shouldMarkerChangeBeConverted(

const hasCustomHandling = ( ancestors as Array<ModelElement> ).some( element => {
if ( range.containsItem( element ) ) {
const viewElement = mapper.toViewElement( element )!;
const viewElement = mapper.toViewElement( element );

return !!viewElement.getCustomProperty( 'addHighlight' );
if ( viewElement ) {
return !!viewElement.getCustomProperty( 'addHighlight' );
}
// Technically, a marker can be created on an element that doesn't have a corresponding view element.
// This happens, for example, with the `title` element, which exists in the model but doesn't have
// a direct view representation (only its child `title-content` is converted to `<h1>` in the view).
// In such cases, we return `false` to indicate that there's no custom handling, allowing the marker
// conversion to proceed normally.
else {
return false;
}
}
} );

Expand Down
27 changes: 27 additions & 0 deletions packages/ckeditor5-engine/tests/conversion/downcastdispatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -1262,6 +1262,33 @@ describe( 'DowncastDispatcher', () => {
expect( dispatcher._conversionApi.writer ).to.be.undefined;
expect( dispatcher._conversionApi.consumable ).to.be.undefined;
} );

it( 'should fire event for marker if element has no view element', () => {
root._removeChildren( 0, root.childCount );

// Create a model element that doesn't have a view element mapping.
const titleContent = new ModelText( 'abc' );
const title = new ModelElement( 'title', null, titleContent );

root._appendChild( [ title ] );

model.change( writer => {
const range = writer.createRange( writer.createPositionAt( root, 0 ), writer.createPositionAt( root, 1 ) );
writer.addMarker( 'name', { range, usingOperation: false } );
writer.setSelection( title, 0 );
} );

sinon.spy( dispatcher, 'fire' );

const markers = Array.from( model.markers.getMarkersAtPosition( doc.selection.getFirstPosition() ) );

dispatcher.convertSelection( doc.selection, model.markers, markers );

expect( dispatcher.fire.calledWith( 'addMarker:name' ) ).to.be.true;

expect( dispatcher._conversionApi.writer ).to.be.undefined;
expect( dispatcher._conversionApi.consumable ).to.be.undefined;
} );
} );

describe( '_convertMarkerAdd', () => {
Expand Down
79 changes: 58 additions & 21 deletions packages/ckeditor5-heading/src/title.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
} from 'ckeditor5/src/engine.js';

// A list of element names that should be treated by the Title plugin as title-like.
// This means that an element of a type from this list will be changed to a title element
// This means that an element of a type from this list will be changed to a `title` element
// when it is the first element in the root.
const titleLikeElements = new Set( [ 'paragraph', 'heading1', 'heading2', 'heading3', 'heading4', 'heading5', 'heading6' ] );

Expand Down Expand Up @@ -74,7 +74,7 @@ export class Title extends Plugin {
const editor = this.editor;
const model = editor.model;

// To use the schema for disabling some features when the selection is inside the title element
// To use the schema for disabling some features when the selection is inside the `title` element
// it is needed to create the following structure:
//
// <title>
Expand All @@ -86,14 +86,14 @@ export class Title extends Plugin {
model.schema.register( 'title-content', { isBlock: true, allowIn: 'title', allowAttributes: [ 'alignment' ] } );
model.schema.extend( '$text', { allowIn: 'title-content' } );

// Disallow all attributes in `title-content`.
// Disallow all attributes in `title-content` `$text`.
model.schema.addAttributeCheck( context => {
if ( context.endsWith( 'title-content $text' ) ) {
return false;
}
} );

// Because `title` is represented by two elements in the model
// Because `title` is represented by two elements (`title` and `title-content`) in the model
// but only one in the view, it is needed to adjust Mapper.
editor.editing.mapper.on<MapperModelToViewPositionEvent>( 'modelToViewPosition', mapModelPositionToView( editor.editing.view ) );
editor.data.mapper.on<MapperModelToViewPositionEvent>( 'modelToViewPosition', mapModelPositionToView( editor.editing.view ) );
Expand All @@ -107,6 +107,43 @@ export class Title extends Plugin {
}
) );

// This converter intercepts attribute changes on `title` and redirects them to `title-content`.
//
// The `title` element in the model doesn't have a corresponding view element, only `title-content`
// is converted to `<h1>`. Setting an attribute on `title` would cause a downcast conversion error
// because there's no view element to apply it to.
editor.conversion.for( 'downcast' ).add( dispatcher => {
dispatcher.on( 'attribute', ( evt, data, conversionApi ) => {
// Only process if the attribute change is on a `title` element.
if ( !data.item.is( 'element', 'title' ) ) {
return;
}

const titleElement = data.item as ModelElement;

if ( !conversionApi.consumable.consume( titleElement, evt.name ) ) {
return;
}

const titleContentElement = titleElement.getChild( 0 ) as ModelElement;

// Ensure the `title-content` element is converted to view first.
// This is needed because attribute conversion might run before insert conversion.
conversionApi.convertItem( titleContentElement );

const viewElement = conversionApi.mapper.toViewElement( titleContentElement ) as ViewElement;
const attributeKey = data.attributeKey;
const attributeNewValue = data.attributeNewValue;

// Apply the attribute to the view element.
if ( attributeNewValue !== null ) {
conversionApi.writer.setAttribute( attributeKey, attributeNewValue, viewElement );
} else {
conversionApi.writer.removeAttribute( attributeKey, viewElement );
}
} );
} );

// Custom converter is used for data v -> m conversion to avoid calling post-fixer when setting data.
// See https://github.com/ckeditor/ckeditor5/issues/2036.
editor.data.upcastDispatcher.on<UpcastElementEvent>( 'element:h1', dataViewModelH1Insertion, { priority: 'high' } );
Expand Down Expand Up @@ -134,10 +171,10 @@ export class Title extends Plugin {

/**
* Returns the title of the document. Note that because this plugin does not allow any formatting inside
* the title element, the output of this method will be a plain text, with no HTML tags.
* the `title` element, the output of this method will be a plain text, with no HTML tags.
*
* It is not recommended to use this method together with features that insert markers to the
* data output, like comments or track changes features. If such markers start in the title and end in the
* data output, like comments or track changes features. If such markers start in the `title` and end in the
* body, the result of this method might be incorrect.
*
* @param options Additional configuration passed to the conversion process.
Expand Down Expand Up @@ -194,7 +231,7 @@ export class Title extends Plugin {
data.mapper.bindElements( root, viewDocumentFragment );
data.downcastDispatcher.convert( rootRange, markers, viewWriter, options );

// Remove title element from view.
// Remove `title` element from view.
viewWriter.remove( viewWriter.createRangeOn( viewDocumentFragment.getChild( 0 ) ) );

// view -> data
Expand Down Expand Up @@ -247,8 +284,8 @@ export class Title extends Plugin {
}

/**
* Model post-fixer callback that creates a title element when it is missing,
* takes care of the correct position of it and removes additional title elements.
* Model post-fixer callback that creates a `title` element when it is missing,
* takes care of the correct position of it and removes additional `title` elements.
*/
private _fixTitleElement( writer: ModelWriter ) {
let changed = false;
Expand All @@ -259,7 +296,7 @@ export class Title extends Plugin {
const firstTitleElement = titleElements[ 0 ];
const firstRootChild = modelRoot.getChild( 0 ) as ModelElement;

// When title element is at the beginning of the document then try to fix additional title elements (if there are any).
// When `title` element is at the beginning of the document then try to fix additional `title` elements (if there are any).
if ( firstRootChild.is( 'element', 'title' ) ) {
if ( titleElements.length > 1 ) {
fixAdditionalTitleElements( titleElements, writer, model );
Expand All @@ -284,10 +321,10 @@ export class Title extends Plugin {
}

if ( titleLikeElements.has( firstRootChild.name ) ) {
// Change the first element in the document to the title if it can be changed (is title-like).
// Change the first element in the document to the `title` if it can be changed (is title-like).
changeElementToTitle( firstRootChild, writer, model );
} else {
// Otherwise, move the first occurrence of the title element to the beginning of the document.
// Otherwise, move the first occurrence of the `title` element to the beginning of the document.
writer.move( writer.createRangeOn( firstTitleElement ), modelRoot, 0 );
}

Expand Down Expand Up @@ -358,7 +395,7 @@ export class Title extends Plugin {
sourceElement && sourceElement.tagName.toLowerCase() === 'textarea' && sourceElement.getAttribute( 'placeholder' ) ||
t( 'Type or paste your content here.' );

// Attach placeholder to the view title element.
// Attach placeholder to the view `title-content` element.
editor.editing.downcastDispatcher.on<DowncastInsertEvent<ModelElement>>( 'insert:title-content', ( evt, data, conversionApi ) => {
const element: PlaceholderableViewElement = conversionApi.mapper.toViewElement( data.item )!;

Expand All @@ -371,8 +408,8 @@ export class Title extends Plugin {
} );
} );

// Attach placeholder to first element after a title element and remove it if it's not needed anymore.
// First element after title can change, so we need to observe all changes keep placeholder in sync.
// Attach placeholder to first element after a `title` element and remove it if it's not needed anymore.
// First element after `title` can change, so we need to observe all changes keep placeholder in sync.
const bodyViewElements = new Map<string, ViewElement>();

// This post-fixer runs after the model post-fixer, so we can assume that the second child in view root will always exist.
Expand Down Expand Up @@ -467,7 +504,7 @@ export class Title extends Plugin {
}

/**
* A view-to-model converter for the h1 that appears at the beginning of the document (a title element).
* A view-to-model converter for the `h1` that appears at the beginning of the document (a `title` element).
*
* @see module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element
* @param evt An object containing information about the fired event.
Expand Down Expand Up @@ -530,7 +567,7 @@ function isTitle( element: ModelElement ) {
}

/**
* Changes the given element to the title element.
* Changes the given element to the `title` element.
*/
function changeElementToTitle( element: ModelElement, writer: ModelWriter, model: Model ) {
const title = writer.createElement( 'title' );
Expand All @@ -542,7 +579,7 @@ function changeElementToTitle( element: ModelElement, writer: ModelWriter, model
}

/**
* Loops over the list of title elements and fixes additional ones.
* Loops over the list of `title` elements and fixes additional ones.
*
* @returns Returns true when there was any change. Returns false otherwise.
*/
Expand All @@ -561,13 +598,13 @@ function fixAdditionalTitleElements( titleElements: Array<ModelElement>, writer:
}

/**
* Changes given title element to a paragraph or removes it when it is empty.
* Changes given `title` element to a paragraph or removes it when it is empty.
*/
function fixTitleElement( title: ModelElement, writer: ModelWriter, model: Model ) {
const child = title.getChild( 0 ) as ModelElement;

// Empty title should be removed.
// It is created as a result of pasting to the title element.
// Empty `title` should be removed.
// It is created as a result of pasting to the `title` element.
if ( child.isEmpty ) {
writer.remove( title );

Expand Down
105 changes: 105 additions & 0 deletions packages/ckeditor5-heading/tests/title.js
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,111 @@ describe( 'Title', () => {
} );
} );

describe( 'attribute conversion', () => {
it( 'should redirect attributes from `title` to `title-content` in downcast', () => {
// Allow a test attribute on both `title` and `title-content`.
model.schema.extend( 'title', { allowAttributes: [ 'data-test' ] } );
model.schema.extend( 'title-content', { allowAttributes: [ 'data-test' ] } );

_setModelData( model,
'<title><title-content>Foo</title-content></title>' +
'<paragraph>Bar</paragraph>'
);

const title = model.document.getRoot().getChild( 0 );

model.change( writer => {
writer.setAttribute( 'data-test', 'value', title );
} );

expect( editor.getData() ).to.equal( '<h1 data-test="value">Foo</h1><p>Bar</p>' );

// The attribute is on `title` in the model, but appears on `h1` (from `title-content`) in the view.
expect( title.hasAttribute( 'data-test' ) ).to.equal( true );
} );

it( 'should redirect multiple attributes from title to title-content in downcast', () => {
model.schema.extend( 'title', { allowAttributes: [ 'data-test1', 'data-test2', 'class' ] } );
model.schema.extend( 'title-content', { allowAttributes: [ 'data-test1', 'data-test2', 'class' ] } );

_setModelData( model,
'<title><title-content>Foo</title-content></title>' +
'<paragraph>Bar</paragraph>'
);

const title = model.document.getRoot().getChild( 0 );

model.change( writer => {
writer.setAttribute( 'data-test1', 'value1', title );
writer.setAttribute( 'data-test2', 'value2', title );
writer.setAttribute( 'class', 'my-class', title );
} );

const data = editor.getData();

expect( data ).to.include( 'data-test1="value1"' );
expect( data ).to.include( 'data-test2="value2"' );
expect( data ).to.include( 'class="my-class"' );
} );

it( 'should remove attributes from view when removed from title', () => {
model.schema.extend( 'title', { allowAttributes: [ 'data-test' ] } );
model.schema.extend( 'title-content', { allowAttributes: [ 'data-test' ] } );

_setModelData( model,
'<title><title-content>Foo</title-content></title>' +
'<paragraph>Bar</paragraph>'
);

const title = model.document.getRoot().getChild( 0 );

model.change( writer => {
writer.setAttribute( 'data-test', 'value', title );
} );

expect( editor.getData() ).to.equal( '<h1 data-test="value">Foo</h1><p>Bar</p>' );

model.change( writer => {
writer.removeAttribute( 'data-test', title );
} );

expect( editor.getData() ).to.equal( '<h1>Foo</h1><p>Bar</p>' );
} );

it( 'should not redirect attributes if event was already consumed', () => {
model.schema.extend( 'title', { allowAttributes: [ 'data-test' ] } );
model.schema.extend( 'title-content', { allowAttributes: [ 'data-test' ] } );

_setModelData( model,
'<title><title-content>Foo</title-content></title>' +
'<paragraph>Bar</paragraph>'
);

const title = model.document.getRoot().getChild( 0 );

// Add a converter with higher priority that consumes the attribute event for `title`.
// This simulates a situation where another converter has already processed the event.
editor.conversion.for( 'downcast' ).add( dispatcher => {
dispatcher.on( 'attribute', ( evt, data, conversionApi ) => {
if ( data.item.is( 'element', 'title' ) ) {
conversionApi.consumable.consume( data.item, evt.name );
}
}, { priority: 'high' } );
} );

model.change( writer => {
writer.setAttribute( 'data-test', 'value', title );
} );

// Since the event was consumed by the higher priority converter,
// our converter should not process it and the attribute should not appear in the view.
expect( editor.getData() ).to.equal( '<h1>Foo</h1><p>Bar</p>' );

// The attribute is still on `title` in the model.
expect( title.hasAttribute( 'data-test' ) ).to.equal( true );
} );
} );

describe( 'Tab press handling', () => {
it( 'should handle tab key when the selection is at the beginning of the title', () => {
_setModelData( model,
Expand Down