Introduction
Before I get to the code I'd like to take the occasion to explain the CKEditor 5 approach to inline elements (like <a>
) so that the solution is easier to understand. With that knowledge, similar problems in the future should not be troubling. Following is meant to be a comprehensive tutorial, so expect a long read.
Even though you may know most of the things in the theory part, I recommend reading it to get the full understanding of how things work in CKEditor 5.
Also, do note that I will present a solution for the original CKEditor 5 plugin as it will be more valuable to other community members seeking a tutorial on this matter. Still, I hope that with the insight from this tutorial you will be able to adjust the code sample to your custom plugin.
Also, keep in mind that this tutorial does not discuss UI part of this plugin, only how things should be configured for conversion purposes. Adding and removing attributes is the job for UI or for some other part of code. Here I discuss only engine stuff.
Inline elements in CKEditor 5
First, let's establish which elements are inline. By inline elements I understand elements like <strong>
, <a>
or <span>
. Unlike <p>
, <blockquote>
or <div>
, inline elements do not structure the data. Instead, they mark some text in a specific (visual and semantical) way. So, in a way, these elements are a characteristic of a given part of a text. As a result, we say that given part of a text is bold, or that given part of a text is/has a link.
Similarly, in the model, we don't represent <a>
or <strong>
directly as elements. Instead, we allow adding attributes to a part of a text. This is how text characteristics (as bold, italic or link) are represented.
For example, in the model, we might have a <paragraph>
element with Foo bar
text, where bar
has the bold
attribute set to true
. We would note it this way: <paragraph>Foo <$text bold="true">bar</$text></paragraph>
. See, that there is no <strong>
or any other additional element there. It's just some text with an attribute. Later, the bold
attribute is converted to <strong>
element.
By the way: view elements that come from model attributes have their own class: view.AttributeElement
and instead of inline elements can be also called attribute elements. Sadly, the name conflicts with "attribute" as an attribute of a view element (what is worse, attribute element is allowed to have attributes).
Of course, text may have multiple attributes and all of them are converted to their respective view inline elements. Keep in mind that in the model, attributes do not have any set order. This is contrary to the view or HTML, where inline elements are nested one in another. The nesting happens during conversion from the model to the view. This makes working in model simpler, as features do not need to take care of breaking or rearranging elements in the model.
Consider this model string:
<paragraph>
<$text bold="true">Foo </$text>
<$text bold="true" linkHref="bar.html">bar</$text>
<$text bold="true"> baz</$text>
</paragraph>
It is a bold Foo bar baz
text with a link on bar
. During conversion, it will be converted to:
<p>
<strong>Foo </strong><a href="bar.html"><strong>bar</strong></a><strong> baz</strong>
</p>
Note, that the <a>
element is converted in a way that it is always the topmost element. This is intentional so that none element will ever break an <a>
element. See this, incorrect view/HTML string:
<p>
<a href="bar.html">Foo </a><strong><a href="bar.html">bar</a></strong>
</p>
The generated view/HTML has two link elements next to each other, which is wrong.
We use priority
property of view.AttributeElement
to define which element should be on top of others. Most elements, like <strong>
do not care about it and keep the default priority. However, <a>
element has changed priority to guarantee a proper order in the view/HTML.
Complex inline elements and merging
So far we mostly discussed the simpler inline elements, i.e. elements which don't have attributes. Examples are <strong>
, <em>
. In contrary, <a>
has additional attributes.
It is easy to come up with features that need to mark/style a part of a text but are custom enough so that simply using a tag is not enough. An example would be a font family feature. When used, it adds fontFamily
attribute to a text, which is later converted to <span>
element with an appropriate style
attribute.
At this point, you need to ask what should happen if multiple such attributes are set on the same part of a text? Take this model example:
<paragraph>
<$text fontFamily="Tahoma" fontSize="big">Foo</$text>
</paragraph>
The above attributes convert as follow:
fontFamily="value"
converts to <span style="font-family: value;">
,
fontSize="value"
converts to <span class="text-value">
.
So, what kind of view/HTML could we expect?
<p>
<span style="font-family: Tahoma;">
<span class="text-big">Foo</span>
</span>
</p>
This, however, seems wrong. Why not have just one <span>
element? Wouldn't it be better this way?
<p>
<span style="font-family: Tahoma;" class="text-big">Foo</span>
</p>
To solve situations like these, in CKEditor 5 conversion mechanism we, in fact, introduced a merging mechanism.
In the above scenario, we have two attributes that convert to <span>
. When the first attribute (say, fontFamily
is converted, there is no <span>
in the view yet. So the <span>
is added with the style
attribute. However, when fontSize
is converted, there is already <span>
in the view. view.Writer
recognizes this and checks whether those elements can be merged. The rules are three:
- elements must have the same
view.Element#name
,
- elements must have the same
view.AttributeElement#priority
,
- neither element may have
view.AttributeElement#id
set.
We haven't discussed id
property yet but, for simplicity reasons, I won't talk about it now. It is enough to say that it is important for some attribute elements to prevent merging them.
Adding another attribute to the link
At this point, it should be pretty clear how to add another attribute to <a>
element.
All that needs to be done is defining a new model attribute (linkTarget
or linkRel
) and make it convert to <a>
element with the desired (target="..."
or rel="..."
) attribute. Then, it will be merged with the original <a href="...">
element.
Keep in mind that <a>
element from the original CKEditor 5 link plugin has custom priority
specified. This means that the element generated by the new plugin need to have the same priority specified to be properly merged.
Upcasting merged attribute elements
For now, we only discussed downcasting (i.e. converting from the model to the view). Now let's talk about upcasting (i.e. converting from the view to the model). Fortunately, it is easier than the previous part.
There are two "things" that can be upcasted - elements and attributes. No magic here - elements are elements (<p>
, <a>
, <strong>
, etc.) and attributes are attributes (class=""
, href=""
, etc.).
Elements can be upcast to elements (<p>
-> <paragraph>
) or attributes (<strong>
-> bold
, <a>
-> linkHref
). Attributes can be upcast to attributes.
Our example clearly needs upcasting from an element to an attribute. Indeed, <a>
element is converted to linkHref
attribute and the linkHref
attribute value is taken from href=""
attribute of the <a>
element.
Naturally, one would define the same conversion for their new linkTarget
or linkRel
attribute. However, there is a trap here. Each part of the view can be converted ("consumed") only once (this is also true for the model when downcasting).
What does it mean? Simply, if one feature already converted given element name or given element attribute, neither feature can also convert it. This way features can correctly overwrite each other. This also means that general-purpose converters can be introduced (for example, <div>
can be converted to <paragraph>
if no other feature recognized <div>
as something that can be converted by that feature). This also helps to spot conflicting converters.
Back to our example. We cannot define two element-to-attribute converters that convert the same element (<a>
) and expect them to work together at the same time. One will overwrite the other.
Since we don't want to change the original link plugin, we need to keep that converter as is. However, the upcast converter for the new plugin will be an attribute-to-attribute converter. Since that converter won't convert element (or rather, element name) it will work together with the original converter.
Code sample
Here is a code sample for a link target plugin. Below I will explain some parts of it.
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import { downcastAttributeToElement } from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters';
import { upcastAttributeToAttribute } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
class LinkTarget extends Plugin {
init() {
const editor = this.editor;
editor.model.schema.extend( '$text', { allowAttributes: 'linkTarget' } );
editor.conversion.for( 'downcast' ).add( downcastAttributeToElement( {
model: 'linkTarget',
view: ( attributeValue, writer ) => {
return writer.createAttributeElement( 'a', { target: attributeValue }, { priority: 5 } );
},
converterPriority: 'low'
} ) );
editor.conversion.for( 'upcast' ).add( upcastAttributeToAttribute( {
view: {
name: 'a',
key: 'target'
},
model: 'linkTarget',
converterPriority: 'low'
} ) );
}
}
For such a long tutorial it surely is a small snippet. Hopefully, most of it is self-explanatory.
First, we expand Schema
by defining a new attribute linkTarget
that is allowed on text.
Then, we define downcast conversion. downcastAttributeToElement
is used as we want to create <a target="...">
element that will be merged with the original <a>
element. Keep in mind that the <a>
element that is created here has the priority defined to 5
, just as in the original link plugin.
The last step is upcast conversion. upcastAttributeToAttribute
helper is used, as discussed earlier. In view
configuration, it is specified that only target
attribute of <a>
element should be converted (name: 'a'
). This does not mean that <a>
element will be converted! This is only filtering configuration for the converter, so it won't convert target
attribute of some other element.
Lastly, both converters are added with priority lower than the original converters to prevent any hypothetical problems.
The above sample works for me on the current master of ckeditor5-engine
and ckeditor5-link
.