@ckeditor/ckeditor5-html-support
Version:
HTML Support feature for CKEditor 5.
226 lines (225 loc) • 10.2 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import { Plugin } from 'ckeditor5/src/core.js';
import { uid } from 'ckeditor5/src/utils.js';
/**
* The HTML comment feature. It preserves the HTML comments (`<!-- -->`) in the editor data.
*
* For a detailed overview, check the {@glink features/html/html-comments HTML comment feature documentation}.
*/
export class HtmlComment extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return 'HtmlComment';
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const loadedCommentsContent = new Map();
editor.data.processor.skipComments = false;
// Allow storing comment's content as the $root attribute with the name `$comment:<unique id>`.
editor.model.schema.addAttributeCheck((context, attributeName) => {
if (context.endsWith('$root') && attributeName.startsWith('$comment')) {
return true;
}
});
// Convert the `$comment` view element to `$comment:<unique id>` marker and store its content (the comment itself) as a $root
// attribute. The comment content is needed in the `dataDowncast` pipeline to re-create the comment node.
editor.conversion.for('upcast').elementToMarker({
view: '$comment',
model: viewElement => {
const markerUid = uid();
const markerName = `$comment:${markerUid}`;
const commentContent = viewElement.getCustomProperty('$rawContent');
loadedCommentsContent.set(markerName, commentContent);
return markerName;
}
});
// Convert the `$comment` marker to `$comment` UI element with `$rawContent` custom property containing the comment content.
editor.conversion.for('dataDowncast').markerToElement({
model: '$comment',
view: (modelElement, { writer }) => {
let root = undefined;
for (const rootName of this.editor.model.document.getRootNames()) {
root = this.editor.model.document.getRoot(rootName);
if (root.hasAttribute(modelElement.markerName)) {
break;
}
}
const markerName = modelElement.markerName;
const commentContent = root.getAttribute(markerName);
const comment = writer.createUIElement('$comment');
writer.setCustomProperty('$rawContent', commentContent, comment);
return comment;
}
});
// Remove comments' markers and their corresponding $root attributes, which are moved to the graveyard.
editor.model.document.registerPostFixer(writer => {
let changed = false;
const markers = editor.model.document.differ.getChangedMarkers().filter(marker => marker.name.startsWith('$comment:'));
for (const marker of markers) {
const { oldRange, newRange } = marker.data;
if (oldRange && newRange && oldRange.root == newRange.root) {
// The marker was moved in the same root. Don't do anything.
continue;
}
if (oldRange) {
// The comment marker was moved from one root to another (most probably to the graveyard).
// Remove the related attribute from the previous root.
const oldRoot = oldRange.root;
if (oldRoot.hasAttribute(marker.name)) {
writer.removeAttribute(marker.name, oldRoot);
changed = true;
}
}
if (newRange) {
const newRoot = newRange.root;
if (newRoot.rootName == '$graveyard') {
// Comment marker was moved to the graveyard -- remove it entirely.
writer.removeMarker(marker.name);
changed = true;
}
else if (!newRoot.hasAttribute(marker.name)) {
// Comment marker was just added or was moved to another root - updated roots attributes.
//
// Added fallback to `''` for the comment content in case if someone incorrectly added just the marker "by hand"
// and forgot to add the root attribute or add them in different change blocks.
//
// It caused an infinite loop in one of the unit tests.
writer.setAttribute(marker.name, loadedCommentsContent.get(marker.name) || '', newRoot);
changed = true;
}
}
}
return changed;
});
// Delete all comment markers from the document before setting new data.
editor.data.on('set', () => {
for (const commentMarker of editor.model.markers.getMarkersGroup('$comment')) {
this.removeHtmlComment(commentMarker.name);
}
}, { priority: 'high' });
// Delete all comment markers that are within a removed range.
// Delete all comment markers at the limit element boundaries if the whole content of the limit element is removed.
editor.model.on('deleteContent', (evt, [selection]) => {
for (const range of selection.getRanges()) {
const limitElement = editor.model.schema.getLimitElement(range);
const firstPosition = editor.model.createPositionAt(limitElement, 0);
const lastPosition = editor.model.createPositionAt(limitElement, 'end');
let affectedCommentIDs;
if (firstPosition.isTouching(range.start) && lastPosition.isTouching(range.end)) {
affectedCommentIDs = this.getHtmlCommentsInRange(editor.model.createRange(firstPosition, lastPosition));
}
else {
affectedCommentIDs = this.getHtmlCommentsInRange(range, { skipBoundaries: true });
}
for (const commentMarkerID of affectedCommentIDs) {
this.removeHtmlComment(commentMarkerID);
}
}
}, { priority: 'high' });
}
/**
* Creates an HTML comment on the specified position and returns its ID.
*
* *Note*: If two comments are created at the same position, the second comment will be inserted before the first one.
*
* @returns Comment ID. This ID can be later used to e.g. remove the comment from the content.
*/
createHtmlComment(position, content) {
const id = uid();
const editor = this.editor;
const model = editor.model;
const root = model.document.getRoot(position.root.rootName);
const markerName = `$comment:${id}`;
return model.change(writer => {
const range = writer.createRange(position);
writer.addMarker(markerName, {
usingOperation: true,
affectsData: true,
range
});
writer.setAttribute(markerName, content, root);
return markerName;
});
}
/**
* Removes an HTML comment with the given comment ID.
*
* It does nothing and returns `false` if the comment with the given ID does not exist.
* Otherwise it removes the comment and returns `true`.
*
* Note that a comment can be removed also by removing the content around the comment.
*
* @param commentID The ID of the comment to be removed.
* @returns `true` when the comment with the given ID was removed, `false` otherwise.
*/
removeHtmlComment(commentID) {
const editor = this.editor;
const marker = editor.model.markers.get(commentID);
if (!marker) {
return false;
}
editor.model.change(writer => {
writer.removeMarker(marker);
});
return true;
}
/**
* Gets the HTML comment data for the comment with a given ID.
*
* Returns `null` if the comment does not exist.
*/
getHtmlCommentData(commentID) {
const editor = this.editor;
const marker = editor.model.markers.get(commentID);
if (!marker) {
return null;
}
let content = '';
for (const root of this.editor.model.document.getRoots()) {
if (root.hasAttribute(commentID)) {
content = root.getAttribute(commentID);
break;
}
}
return {
content,
position: marker.getStart()
};
}
/**
* Gets all HTML comments in the given range.
*
* By default, it includes comments at the range boundaries.
*
* @param range The range to search for HTML comments.
* @param options Additional options.
* @param options.skipBoundaries When set to `true` the range boundaries will be skipped.
* @returns HTML comment IDs
*/
getHtmlCommentsInRange(range, { skipBoundaries = false } = {}) {
const includeBoundaries = !skipBoundaries;
// Unfortunately, MarkerCollection#getMarkersAtPosition() filters out collapsed markers.
return Array.from(this.editor.model.markers.getMarkersGroup('$comment'))
.filter(marker => isCommentMarkerInRange(marker, range))
.map(marker => marker.name);
function isCommentMarkerInRange(commentMarker, range) {
const position = commentMarker.getRange().start;
return ((position.isAfter(range.start) || (includeBoundaries && position.isEqual(range.start))) &&
(position.isBefore(range.end) || (includeBoundaries && position.isEqual(range.end))));
}
}
}