@ckeditor/ckeditor5-html-embed
Version:
HTML embed feature for CKEditor 5.
335 lines (334 loc) • 14.8 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
*/
/**
* @module html-embed/htmlembedediting
*/
import { Plugin } from 'ckeditor5/src/core.js';
import { ButtonView } from 'ckeditor5/src/ui.js';
import { toWidget } from 'ckeditor5/src/widget.js';
import { logWarning, createElement } from 'ckeditor5/src/utils.js';
import { IconCancel, IconCheck, IconPencil } from 'ckeditor5/src/icons.js';
import HtmlEmbedCommand from './htmlembedcommand.js';
import '../theme/htmlembed.css';
/**
* The HTML embed editing feature.
*/
export default class HtmlEmbedEditing extends Plugin {
/**
* Keeps references to {@link module:ui/button/buttonview~ButtonView edit, save, and cancel} button instances created for
* each widget so they can be destroyed if they are no longer in DOM after the editing view was re-rendered.
*/
_widgetButtonViewReferences = new Set();
/**
* @inheritDoc
*/
static get pluginName() {
return 'HtmlEmbedEditing';
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
constructor(editor) {
super(editor);
editor.config.define('htmlEmbed', {
showPreviews: false,
sanitizeHtml: rawHtml => {
/**
* When using the HTML embed feature with the `config.htmlEmbed.showPreviews` set to `true`, it is strongly recommended to
* define a sanitize function that will clean up the input HTML in order to avoid XSS vulnerability.
*
* For a detailed overview, check the {@glink features/html/html-embed HTML embed feature} documentation.
*
* @error html-embed-provide-sanitize-function
*/
logWarning('html-embed-provide-sanitize-function');
return {
html: rawHtml,
hasChanged: false
};
}
});
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const schema = editor.model.schema;
schema.register('rawHtml', {
inheritAllFrom: '$blockObject',
allowAttributes: ['value']
});
editor.commands.add('htmlEmbed', new HtmlEmbedCommand(editor));
this._setupConversion();
}
/**
* Prepares converters for the feature.
*/
_setupConversion() {
const editor = this.editor;
const t = editor.t;
const view = editor.editing.view;
const widgetButtonViewReferences = this._widgetButtonViewReferences;
const htmlEmbedConfig = editor.config.get('htmlEmbed');
// Destroy UI buttons created for widgets that have been removed from the view document (e.g. in the previous conversion).
// This prevents unexpected memory leaks from UI views.
this.editor.editing.view.on('render', () => {
for (const buttonView of widgetButtonViewReferences) {
if (buttonView.element && buttonView.element.isConnected) {
return;
}
buttonView.destroy();
widgetButtonViewReferences.delete(buttonView);
}
}, { priority: 'lowest' });
// Register div.raw-html-embed as a raw content element so all of it's content will be provided
// as a view element's custom property while data upcasting.
editor.data.registerRawContentMatcher({
name: 'div',
classes: 'raw-html-embed'
});
editor.conversion.for('upcast').elementToElement({
view: {
name: 'div',
classes: 'raw-html-embed'
},
model: (viewElement, { writer }) => {
// The div.raw-html-embed is registered as a raw content element,
// so all it's content is available in a custom property.
return writer.createElement('rawHtml', {
value: viewElement.getCustomProperty('$rawContent')
});
}
});
editor.conversion.for('dataDowncast').elementToElement({
model: 'rawHtml',
view: (modelElement, { writer }) => {
return writer.createRawElement('div', { class: 'raw-html-embed' }, function (domElement) {
domElement.innerHTML = modelElement.getAttribute('value') || '';
});
}
});
editor.conversion.for('editingDowncast').elementToStructure({
model: { name: 'rawHtml', attributes: ['value'] },
view: (modelElement, { writer }) => {
let domContentWrapper;
let state;
let props;
const viewContentWrapper = writer.createRawElement('div', {
class: 'raw-html-embed__content-wrapper'
}, function (domElement) {
domContentWrapper = domElement;
renderContent({ editor, domElement, state, props });
// Since there is a `data-cke-ignore-events` attribute set on the wrapper element in the editable mode,
// the explicit `mousedown` handler on the `capture` phase is needed to move the selection onto the whole
// HTML embed widget.
domContentWrapper.addEventListener('mousedown', () => {
if (state.isEditable) {
const model = editor.model;
const selectedElement = model.document.selection.getSelectedElement();
// Move the selection onto the whole HTML embed widget if it's currently not selected.
if (selectedElement !== modelElement) {
model.change(writer => writer.setSelection(modelElement, 'on'));
}
}
}, true);
});
// API exposed on each raw HTML embed widget so other features can control a particular widget.
const rawHtmlApi = {
makeEditable() {
state = Object.assign({}, state, {
isEditable: true
});
renderContent({ domElement: domContentWrapper, editor, state, props });
view.change(writer => {
writer.setAttribute('data-cke-ignore-events', 'true', viewContentWrapper);
});
// This could be potentially pulled to a separate method called focusTextarea().
domContentWrapper.querySelector('textarea').focus();
},
save(newValue) {
// If the value didn't change, we just cancel. If it changed,
// it's enough to update the model – the entire widget will be reconverted.
if (newValue !== state.getRawHtmlValue()) {
editor.execute('htmlEmbed', newValue);
editor.editing.view.focus();
}
else {
this.cancel();
}
},
cancel() {
state = Object.assign({}, state, {
isEditable: false
});
renderContent({ domElement: domContentWrapper, editor, state, props });
editor.editing.view.focus();
view.change(writer => {
writer.removeAttribute('data-cke-ignore-events', viewContentWrapper);
});
}
};
state = {
showPreviews: htmlEmbedConfig.showPreviews,
isEditable: false,
getRawHtmlValue: () => modelElement.getAttribute('value') || ''
};
props = {
sanitizeHtml: htmlEmbedConfig.sanitizeHtml,
textareaPlaceholder: t('Paste raw HTML here...'),
onEditClick() {
rawHtmlApi.makeEditable();
},
onSaveClick(newValue) {
rawHtmlApi.save(newValue);
},
onCancelClick() {
rawHtmlApi.cancel();
}
};
const viewContainer = writer.createContainerElement('div', {
class: 'raw-html-embed',
'data-html-embed-label': t('HTML snippet'),
dir: editor.locale.uiLanguageDirection
}, viewContentWrapper);
writer.setCustomProperty('rawHtmlApi', rawHtmlApi, viewContainer);
writer.setCustomProperty('rawHtml', true, viewContainer);
return toWidget(viewContainer, writer, {
label: t('HTML snippet'),
hasSelectionHandle: true
});
}
});
function renderContent({ editor, domElement, state, props }) {
// Remove all children;
domElement.textContent = '';
const domDocument = domElement.ownerDocument;
let domTextarea;
if (state.isEditable) {
const textareaProps = {
isDisabled: false,
placeholder: props.textareaPlaceholder
};
domTextarea = createDomTextarea({ domDocument, state, props: textareaProps });
domElement.append(domTextarea);
}
else if (state.showPreviews) {
const previewContainerProps = {
sanitizeHtml: props.sanitizeHtml
};
domElement.append(createPreviewContainer({ domDocument, state, props: previewContainerProps, editor }));
}
else {
const textareaProps = {
isDisabled: true,
placeholder: props.textareaPlaceholder
};
domElement.append(createDomTextarea({ domDocument, state, props: textareaProps }));
}
const buttonsWrapperProps = {
onEditClick: props.onEditClick,
onSaveClick: () => {
props.onSaveClick(domTextarea.value);
},
onCancelClick: props.onCancelClick
};
domElement.prepend(createDomButtonsWrapper({ editor, domDocument, state, props: buttonsWrapperProps }));
}
function createDomButtonsWrapper({ editor, domDocument, state, props }) {
const domButtonsWrapper = createElement(domDocument, 'div', {
class: 'raw-html-embed__buttons-wrapper'
});
if (state.isEditable) {
const saveButtonView = createUIButton(editor, 'save', props.onSaveClick);
const cancelButtonView = createUIButton(editor, 'cancel', props.onCancelClick);
domButtonsWrapper.append(saveButtonView.element, cancelButtonView.element);
widgetButtonViewReferences.add(saveButtonView).add(cancelButtonView);
}
else {
const editButtonView = createUIButton(editor, 'edit', props.onEditClick);
domButtonsWrapper.append(editButtonView.element);
widgetButtonViewReferences.add(editButtonView);
}
return domButtonsWrapper;
}
function createDomTextarea({ domDocument, state, props }) {
const domTextarea = createElement(domDocument, 'textarea', {
placeholder: props.placeholder,
class: 'ck ck-reset ck-input ck-input-text raw-html-embed__source'
});
domTextarea.disabled = props.isDisabled;
domTextarea.value = state.getRawHtmlValue();
return domTextarea;
}
function createPreviewContainer({ editor, domDocument, state, props }) {
const sanitizedOutput = props.sanitizeHtml(state.getRawHtmlValue());
const placeholderText = state.getRawHtmlValue().length > 0 ?
t('No preview available') :
t('Empty snippet content');
const domPreviewPlaceholder = createElement(domDocument, 'div', {
class: 'ck ck-reset_all raw-html-embed__preview-placeholder'
}, placeholderText);
const domPreviewContent = createElement(domDocument, 'div', {
class: 'raw-html-embed__preview-content',
dir: editor.locale.contentLanguageDirection
});
// Creating a contextual document fragment allows executing scripts when inserting into the preview element.
// See: #8326.
const domRange = domDocument.createRange();
const domDocumentFragment = domRange.createContextualFragment(sanitizedOutput.html);
domPreviewContent.appendChild(domDocumentFragment);
const domPreviewContainer = createElement(domDocument, 'div', {
class: 'raw-html-embed__preview'
}, [
domPreviewPlaceholder, domPreviewContent
]);
return domPreviewContainer;
}
}
}
/**
* Returns a UI button view that can be used in conversion.
*/
function createUIButton(editor, type, onClick) {
const { t } = editor.locale;
const buttonView = new ButtonView(editor.locale);
const command = editor.commands.get('htmlEmbed');
buttonView.set({
class: `raw-html-embed__${type}-button`,
icon: IconPencil,
tooltip: true,
tooltipPosition: editor.locale.uiLanguageDirection === 'rtl' ? 'e' : 'w'
});
buttonView.render();
if (type === 'edit') {
buttonView.set({
icon: IconPencil,
label: t('Edit source')
});
buttonView.bind('isEnabled').to(command);
}
else if (type === 'save') {
buttonView.set({
icon: IconCheck,
label: t('Save changes')
});
buttonView.bind('isEnabled').to(command);
}
else {
buttonView.set({
icon: IconCancel,
label: t('Cancel')
});
}
buttonView.on('execute', onClick);
return buttonView;
}