Tech Deep Dive: Exploring CKEditor and Prosemirror’s Core Designs

Oleg Petrov
Juro Tech
Published in
12 min readJan 26, 2024

In the realm of web-based content editing, the underlying architecture of a text editor is crucial in determining its capabilities, efficiency, and UX. This in-depth article offers a technical comparison between two widely used editors in the web development community: CKEditor and Prosemirror. The emphasis here is on examining their schemas and architectural frameworks to understand their impact on editor functionality and developer control.

CKEditor 5 employs a Model-View-Controller (MVC) architecture. This structured approach facilitates high-level abstraction, catering to ease of use and seamless integration. CKEditor 5 is further characterized by its extensive feature set, robust support for collaborative editing, and a plugin-based customization system, making it an attractive option for developers seeking a comprehensive, out-of-the-box solution.

Prosemirror, on the other hand, adopts a contrasting stance with its flexible and low-level API. This design choice grants developers a granular level of control over content manipulation and editor behavior, favoring customization and intricate content handling strategies. Prosemirror’s approach is especially appealing to developers requiring a highly customizable editor that can be tailored to specific content structures and user interactions.

The article delves into each editor’s architectural design, assessing their strengths, weaknesses, and practical applications in web development.

High-Level Basic Editors Comparison

When comparing CKEditor and Prosemirror, it’s insightful to look at basic code examples to understand their fundamental differences in approach and implementation.

Prosemirror

Prosemirror’s architecture allows for a high degree of customization, as demonstrated in the following code snippet. This example showcases the definition of a custom node within Prosemirror’s schema and a plugin implementation:

// schema with a custom node
import { Schema, NodeSpec } from "prosemirror-model";

const myCustomNodeType: NodeSpec = {
content: "text*",
toDOM: () => ["mycustomnode", 0]
};

const mySchema = new Schema({
nodes: {
doc: { content: "block+" },
text: { group: "inline" },
mycustomnode: myCustomNodeType
// ... other nodes ...
},
marks: {
// ... marks ...
}
});

// plugin example
import { Plugin, PluginKey } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";

const myPluginKey = new PluginKey("myPlugin");

const myPlugin = new Plugin({
key: myPluginKey,
state: {
init() {
return DecorationSet.empty;
},
apply(tr, oldState) {
// Update decorations based on transaction
// For simplicity, let's assume we're always creating one decoration
const decoration = Decoration.node(1, 3, { class: "highlighted" });
return DecorationSet.create(tr.doc, [decoration]);
}
},
props: {
decorations(state) {
return this.getState(state);
}
}
});

// editor.js
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { baseKeymap } from "prosemirror-commands";

const editorState = EditorState.create({
schema: mySchema,
plugins: [myPlugin, keymap(baseKeymap)]
});

const editorView = new EditorView(document.querySelector("#editor"), {
state: editorState
});

CKEditor

In contrast, CKEditor operates with a more structured and high-level approach. The following example demonstrates how CKEditor handles similar functionalities:

// custom node plugin
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';

class MyCustomElement extends Plugin {
init() {
const editor = this.editor;

editor.model.schema.register('myCustomElement', {
// Object structure
isObject: true,
allowWhere: '$block',
// ... other properties ...
});

// Define converters
// ...
}
}

// custom decoration plugin
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import { downcastMarkerToHighlight } from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters';

class MyDecorationPlugin extends Plugin {
init() {
const editor = this.editor;

// Define conversion for the highlight marker
editor.conversion.for('downcast').add(downcastMarkerToHighlight({
model: 'highlightMarker',
view: {
classes: 'highlight'
}
}));

// React to changes in the editor's content
editor.model.document.on('change:data', () => {
this._updateHighlightMarkers();
});
}

_updateHighlightMarkers() {
const model = this.editor.model;
const textToHighlight = "highlight";

model.change(writer => {
// ... logic to add markers ...
});
}
}

// editor initialization
import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';

ClassicEditor
.create(document.querySelector('#editor'), {
plugins: [ MyCustomElement, MyDecorationPlugin, ... ],
// ... other configuration ...
})
.then(editor => {
console.log('Editor was initialized', editor);
})
.catch(error => {
console.error('There was a problem initializing the editor:', error);
});

Model data

The way text editors handle model data is fundamental to their functionality and user experience. This section explores how CKEditor and Prosemirror manage their model data, providing insights into their architectural differences.

Prosemirror

In Prosemirror, the editor’s content is represented as a stateful document model, comprising a tree structure of nodes and marks. These elements define both the content and its formatting. The model in Prosemirror is intricately linked to the view, which serves the dual purpose of displaying the document and managing user interactions like typing, selection, and clicking. An example of Prosemirror’s model data representation is as follows:

{
"doc": {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "Hello, world!"
}
]
}
]
},
"selection": {
"anchor": 1,
"head": 1,
"type": "text"
},
"schema": { ... },
"plugins": [ ... ]
}

CKEditor

Image source: CKEditor 5 Editing engine

CKEditor’s design separates the content model from its view, allowing a more abstracted interaction with the data. The model in CKEditor represents the document’s structure and content independently from its visual representation, enabling a high level of control and abstraction over the content manipulation. Unfortunately, a specific code example of CKEditor’s model data is not provided in the excerpt, but typically, it involves a more structured and high-level representation compared to Prosemirror.

// it's easy to access content via `editor.model`
// https://ckeditor.com/docs/ckeditor5/latest/framework/architecture/editing-engine.html#model

// Defaults to stringified html string
editor.getData() => '<p>Hello world!</p>'

// More complex example
'<p><comment-start name="e97ff34dee81397019236b39682f23fd6:ba6bb"></comment-start>Hello <strong>world</strong>!<comment-end name="e97ff34dee81397019236b39682f23fd6:ba6bb"></comment-end></p>'

Nodes schema

The nodes schema in text editors is a critical aspect, particularly when it comes to crafting custom and complex nodes. This comparison between CKEditor and Prosemirror sheds light on how each editor’s nodes schema contributes to the creation and management of sophisticated document structures.

Prosemirror

Stateful, node-based document model. Each piece of content in the editor is represented as a node in a tree-like structure. The document is composed of nodes (like paragraphs, headings, images) and marks (like bold, italic).

const customNodeSchema = {
attrs: {
id: {},
},
inline: true,
isolating: true,
group: 'inline',
draggable: true,
selectable: true,
toDOM(node) {
return ['custom-node', { 'data-custom-node': node.attrs.id }];
},
parseDOM: [
{
tag: 'custom-node',
getAttrs(dom) {
return {
id: dom.getAttribute('data-custom-node'),
};
},
},
],
};

CKEditor

The CKEditor 5 model is designed to be abstract, representing the semantic content of the document rather than its direct visual representation. The model consists of elements (akin to nodes in ProseMirror) and text nodes. These elements and text nodes are used to construct a tree-like structure representing the document. Elements and text nodes in can have attributes. Markers are distinct from the elements within the document model; they represent a separate, non-content data structure. Unlike elements which are integral parts of the document’s content, markers are used to annotate or highlight ranges in the content without altering the actual structure of the model’s elements.

class CustomNodePlugin extends Plugin {
init() {
const editor = this.editor;

// Define the schema for the custom node
editor.model.schema.register('customNode', {
allowWhere: '$text',
allowContentOf: '$block',
isInline: true,
isObject: true,
});

// Define how the model element is converted to and from the view
editor.conversion.for('upcast').elementToElement({
view: {
name: 'custom-node',
attributes: {
'data-custom-node': true
}
},
model: (viewElement, modelWriter) => {
return modelWriter.createElement('customNode', {
id: viewElement.getAttribute('data-custom-node')
});
}
});

editor.conversion.for('downcast').elementToElement({
model: 'customNode',
view: (modelElement, viewWriter) => {
return viewWriter.createContainerElement('custom-node', {
'data-custom-node': modelElement.getAttribute('id')
});
}
});
}
}

Plugins

The use of plugins is a crucial aspect of extending the capabilities and customizing the user experience in modern text editors. Both CKEditor and Prosemirror leverage plugins, but they do so in distinct ways that reflect their underlying architectures and design philosophies.

Prosemirror

Prosemirror’s architecture, known for its flexibility and low-level API, allows for granular control over content and its representation. This flexibility is especially evident in its approach to plugins. A typical Prosemirror plugin might look like this:

import { Plugin, PluginKey } from 'prosemirror-state';

class CustomNodeManager extends Plugin {
constructor(customNodeClicked) {
super({
key: new PluginKey('CustomNodeManager'),
props: {
// Handling click events on custom nodes
handleClick: (view, pos, event) => {
const { doc, schema } = view.state;
const { node } = doc.resolve(pos);

if (node && node.type === schema.nodes.custom_node) {
// Execute the callback when a custom node is clicked
customNodeClicked(node.attrs);
return true; // Indicate that this click event was handled
}

return false;
},
},
});
}
}

// Usage example:
// Initialize the plugin with the callback function for custom nodes clicks
const customNodeClicked = (attrs) => {
console.log('Custom node clicked with attributes:', attrs);
};

const myCustomNodePlugin = new CustomNodeManager(customNodeClicked);

CKEditor

CKEditor plugins can encapsulate both functionality and UI components, allowing for a more integrated and seamless extension of the editor’s capabilities. A CKEditor plugin example might be structured as follows:

import Plugin from '@ckeditor/ckeditor5-core/src/plugin';

class CustomNodeManager extends Plugin {
setCallback(callback) {
this.callback = callback;
}

init() {
const editor = this.editor;

this.listenTo(editor.editing.view.document, 'click', (evt, data) => {
const modelElement = this._getModelElementFromClick(data);

if (modelElement && modelElement.is('element', 'customNode')) {
this.callback(modelElement);
}
});
}

_getModelElementFromClick(data) {
const editor = this.editor;
const viewDocument = editor.editing.view.document;
const viewPosition = data.target;

const modelPosition = editor.editing.mapper.toModelPosition(viewPosition);
return modelPosition.nodeBefore || modelPosition.nodeAfter;
}
}

const myCustomNodeCallback = (modelElement) => {
// Logic to be executed when a custom node is clicked
console.log('Custom node clicked:', modelElement);
};

// In CKEditor 5, plugins are typically initialized without directly passing
// arguments to their constructors. To do it, you usually have to set it up
// through the editor's configuration or by using the plugin's API after
// the editor has been initialized.
ClassicEditor
.create(document.querySelector('#editor'), {// ... other configuration ...})
.then(editor => {
// Once the editor is initialized, set the callback
const customNodeManager = editor.plugins.get(CustomNodeManager);
customNodeManager.setCallback(myCustomNodeCallback);

return editor;
})
.catch(error => {
console.error(error);
});

Managing Changes

The manner in which text editors handle changes to the document is a critical aspect of their functionality. This section compares how CKEditor and Prosemirror manage changes, each employing its unique mechanisms and structures.

Prosemirror

Prosemirror manages changes using a concept called “transactions”. A transaction in ProseMirror is a set of steps that represent the changes made to the document. It encapsulates all the changes that should happen to the document at once. This can include text insertion, formatting changes, or more complex structural adjustments. Transactions can be extended with metadata to include additional information about the changes. Each transaction consists of one or more steps. Each step is a specific change to the document, like replacing a range of text.

function makeWordBold(state, dispatch) {
const textToStyle = "example";
const {doc, tr} = state;
let updated = false;

doc.descendants((node, pos) => {
if (node.isText) {
const index = node.text.indexOf(textToStyle);
if (index !== -1) {
const from = pos + index;
const to = from + textToStyle.length;
tr.addMark(from, to, state.schema.marks.strong.create());
updated = true;
}
}
});

if (updated) {
dispatch(tr);
}
}


// Another example
const textInsertionTrackerPlugin = new Plugin({
appendTransaction(transactions, oldState, newState) {
let insertedText = '';

transactions.forEach(tr => {
tr.steps.forEach(step => {
// Check for ReplaceStep or ReplaceAroundStep, which indicate content changes
if (step.name === 'ReplaceStep') {
const stepMap = step.getMap();
for (let i = 0; i < stepMap.ranges.length; i += 3) {
const from = stepMap.ranges[i];
const to = stepMap.ranges[i + 1];

const inserted = newState.doc.textBetween(from, to, ' ');
if (inserted) {
insertedText += inserted;
}
}
}
});
});

if (insertedText) {
log('Inserted text in this transaction:', insertedText);
}
}
});

CKEditor

It uses Operational Transformation (OT) to handle and store changes. Changes in CKEditor 5 are represented as operations on the model. These operations could be things like inserting or removing characters, changing attributes, etc. Each operation is an atomic change that transforms the document from one state to another. Changes are buffered in the editor and can be grouped into batches. A batch represents a single undo step and can contain multiple operations. Extending changes with additional metadata is typically achieved through different mechanisms than in ProseMirror. While ProseMirror uses transaction metadata for this purpose, CKEditor 5 primarily relies on its robust plugin architecture and the model’s features like attributes, markers, and operations.

class MakeWordBoldCommand extends Command {
execute() {
const model = this.editor.model;
const textToStyle = "example";

model.change(writer => {
const range = model.createRangeIn(model.document.getRoot());

for (const item of range.getItems()) {
if (item.is('text') && item.data.includes(textToStyle)) {
const index = item.data.indexOf(textToStyle);
const start = writer.createPositionAt(item, index);
const end = writer.createPositionAt(item, index + textToStyle.length);
const bold = writer.createElement('bold');

writer.wrap(writer.createRange(start, end), bold);
}
}
});
}
}

// Another example
class TextInsertionTrackerPlugin extends Plugin {
init() {
const editor = this.editor;

// Listen to changes in the document
editor.model.document.on('change', (evt, batch) => {
// Store all inserted text in this batch
let insertedText = '';

for (const operation of batch.operations) {
if (operation.type === 'insert' && operation.nodes.first.is('$text')) {
const textNode = operation.nodes.first;
insertedText += textNode.data;
}
}

if (insertedText) {
log('Inserted text in this batch:', insertedText);
}
});
}
}

Enhancing Content

The way text editors handle content enhancements, like decorations and markers, is crucial for creating rich and interactive text experiences. Prosemirror and CKEditor each have their own methods to add these visual enhancements.

Prosemirror Decorations

Prosemirror employs a feature called “Decorations” to add visual embellishments to the content. Decorations in Prosemirror are versatile and can be used to highlight text, add inline widgets, or even create complex overlays. Here’s an example demonstrating how decorations can be applied in Prosemirror:

import { Decoration, DecorationSet } from "prosemirror-view";

// Example function to create decorations for a range
function highlightRange(doc, from, to) {
let decorations = [];
doc.nodesBetween(from, to, (node, pos) => {
if (node.isText) {
decorations.push(Decoration.inline(pos, pos + node.nodeSize, { class: 'highlight' }));
}
});
return DecorationSet.create(doc, decorations);
}

const decorationSet = highlightRange(state.doc, 5, 10);

CKEditor Markers / Attributes

CKEditor approaches content enhancement through “Markers” and “Attributes”. These tools allow for marking parts of the content and styling them accordingly. Markers in CKEditor are particularly useful for collaborative editing environments, as they can be used to track changes or highlight content. An example of implementing markers and attributes in CKEditor is as follows:

import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import { downcastMarkerToHighlight } from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters';

class HighlightPlugin extends Plugin {
init() {
const editor = this.editor;

// Define conversion for the highlight marker
editor.conversion.for('downcast').add(downcastMarkerToHighlight({
model: 'highlightMarker',
view: {
classes: 'highlight'
}
}));

// Example function to add a highlight marker
function addHighlightMarker(model, from, to) {
model.change(writer => {
writer.addMarker('highlightMarker', {
range: writer.createRange(writer.createPositionAt(model.document.getRoot(), from),
writer.createPositionAt(model.document.getRoot(), to)),
usingOperation: false,
affectsData: false
});
});
}

// Add a marker to highlight text from position 5 to 10
addHighlightMarker(editor.model, 5, 10);
}
}

// Example of using attributes
editor.model.change(writer => {
const range = writer.createRangeIn(editor.model.document.getRoot());

for (const item of range.getItems()) {
if (item.is('text') && item.data.includes('example')) {
writer.setAttribute('bold', true, item);
}
}
});

Widgets

Widgets in text editors like CKEditor and Prosemirror are advanced components that go beyond basic text and image handling, often encompassing interactive features, embedded media, or custom content blocks. The implementation of these widgets varies between the two editors due to their different architectural approaches.

Prosemirror

import { Decoration, DecorationSet } from "prosemirror-view";
import { Plugin } from "prosemirror-state";

function createPlaceholderWidget(pos) {
const placeholder = document.createElement("span");
placeholder.textContent = "Non-editable Widget";
placeholder.className = "placeholder-widget";
return Decoration.widget(pos, placeholder);
}

const placeholderPlugin = new Plugin({
state: {
init() {
return DecorationSet.create(doc, [createPlaceholderWidget(5)]);
},
apply(tr, set) {
set = set.map(tr.mapping, tr.doc);
return set;
}
},
props: {
decorations(state) {
return this.getState(state);
}
}
});

CKEditor

CKEditor treats widgets as rich content elements, integrating them more closely with the editor’s structured content model:

import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
import Widget from '@ckeditor/ckeditor5-widget/src/widget';

class HardcodedPlaceholderWidget extends Plugin {
static get requires() {
return [ Widget ];
}

init() {
const editor = this.editor;

editor.model.schema.register('placeholder', {
isObject: true,
allowWhere: '$block'
});

editor.conversion.for('downcast').elementToElement({
model: 'placeholder',
view: (modelElement, viewWriter) => {
const widgetElement = viewWriter.createContainerElement('span', { class: 'placeholder-widget' });
return toWidget(widgetElement, viewWriter, { label: 'Non-editable Widget' });
}
});

editor.conversion.for('upcast').elementToElement({
view: {
name: 'span',
classes: 'placeholder-widget'
},
model: 'placeholder'
});

// Insert the widget at a hardcoded position when the editor is ready
editor.model.change(writer => {
const placeholderElement = writer.createElement('placeholder');
// Hardcoded position, e.g., at the start of the document
writer.insert(placeholderElement, writer.createPositionAt(editor.model.document.getRoot(), 5));
});
}
}

Conclusion

In conclusion, this comparison between CKEditor and Prosemirror highlights their distinct architectural approaches and how these influence their functionalities. CKEditor, with its MVC architecture, offers a structured and feature-rich environment ideal for out-of-the-box use and collaborative editing. Prosemirror, with its low-level API, caters to developers needing granular control for custom content structures and intricate handling strategies. Both editors provide robust plugin systems, handling of changes, and content enhancement techniques, yet differ markedly in their approach to nodes schema and widgets, reflecting their unique strengths and suitability for different web development scenarios.

--

--