@ckeditor/ckeditor5-html-embed
Version:
HTML embed feature for CKEditor 5.
532 lines (526 loc) • 21.1 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 { Command, Plugin } from '@ckeditor/ckeditor5-core/dist/index.js';
import { findOptimalInsertionRange, toWidget, Widget } from '@ckeditor/ckeditor5-widget/dist/index.js';
import { ButtonView, MenuBarMenuListItemButtonView } from '@ckeditor/ckeditor5-ui/dist/index.js';
import { logWarning, createElement } from '@ckeditor/ckeditor5-utils/dist/index.js';
import { IconPencil, IconCheck, IconCancel, IconHtml } from '@ckeditor/ckeditor5-icons/dist/index.js';
/**
* The insert HTML embed element command.
*
* The command is registered by {@link module:html-embed/htmlembedediting~HtmlEmbedEditing} as `'htmlEmbed'`.
*
* To insert an empty HTML embed element at the current selection, execute the command:
*
* ```ts
* editor.execute( 'htmlEmbed' );
* ```
*
* You can specify the initial content of a new HTML embed in the argument:
*
* ```ts
* editor.execute( 'htmlEmbed', '<b>Initial content.</b>' );
* ```
*
* To update the content of the HTML embed, select it in the model and pass the content in the argument:
*
* ```ts
* editor.execute( 'htmlEmbed', '<b>New content of an existing embed.</b>' );
* ```
*/ class HtmlEmbedCommand extends Command {
/**
* @inheritDoc
*/ refresh() {
const model = this.editor.model;
const schema = model.schema;
const selection = model.document.selection;
const selectedRawHtmlElement = getSelectedRawHtmlModelWidget(selection);
this.isEnabled = isHtmlEmbedAllowedInParent(selection, schema, model);
this.value = selectedRawHtmlElement ? selectedRawHtmlElement.getAttribute('value') || '' : null;
}
/**
* Executes the command, which either:
*
* * creates and inserts a new HTML embed element if none was selected,
* * updates the content of the HTML embed if one was selected.
*
* @fires execute
* @param value When passed, the value (content) will be set on a new embed or a selected one.
*/ execute(value) {
const model = this.editor.model;
const selection = model.document.selection;
model.change((writer)=>{
let htmlEmbedElement;
// If the command has a non-null value, there must be some HTML embed selected in the model.
if (this.value !== null) {
htmlEmbedElement = getSelectedRawHtmlModelWidget(selection);
} else {
htmlEmbedElement = writer.createElement('rawHtml');
model.insertObject(htmlEmbedElement, null, null, {
setSelection: 'on'
});
}
writer.setAttribute('value', value, htmlEmbedElement);
});
}
}
/**
* Checks if an HTML embed is allowed by the schema in the optimal insertion parent.
*/ function isHtmlEmbedAllowedInParent(selection, schema, model) {
const parent = getInsertHtmlEmbedParent(selection, model);
return schema.checkChild(parent, 'rawHtml');
}
/**
* Returns a node that will be used to insert a html embed with `model.insertContent` to check if a html embed element can be placed there.
*/ function getInsertHtmlEmbedParent(selection, model) {
const insertionRange = findOptimalInsertionRange(selection, model);
const parent = insertionRange.start.parent;
if (parent.isEmpty && !parent.is('rootElement')) {
return parent.parent;
}
return parent;
}
/**
* Returns the selected HTML embed element in the model, if any.
*/ function getSelectedRawHtmlModelWidget(selection) {
const selectedElement = selection.getSelectedElement();
if (selectedElement && selectedElement.is('element', 'rawHtml')) {
return selectedElement;
}
return null;
}
/**
* The HTML embed editing feature.
*/ 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;
}
/**
* The HTML embed UI plugin.
*/ class HtmlEmbedUI extends Plugin {
/**
* @inheritDoc
*/ static get pluginName() {
return 'HtmlEmbedUI';
}
/**
* @inheritDoc
*/ static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/ init() {
const editor = this.editor;
const locale = editor.locale;
const t = locale.t;
// Add the `htmlEmbed` button to feature components.
editor.ui.componentFactory.add('htmlEmbed', ()=>{
const buttonView = this._createButton(ButtonView);
buttonView.set({
tooltip: true,
label: t('Insert HTML')
});
return buttonView;
});
editor.ui.componentFactory.add('menuBar:htmlEmbed', ()=>{
const buttonView = this._createButton(MenuBarMenuListItemButtonView);
buttonView.set({
label: t('HTML snippet')
});
return buttonView;
});
}
/**
* Creates a button for html embed command to use either in toolbar or in menu bar.
*/ _createButton(ButtonClass) {
const editor = this.editor;
const command = editor.commands.get('htmlEmbed');
const view = new ButtonClass(editor.locale);
view.set({
icon: IconHtml
});
view.bind('isEnabled').to(command, 'isEnabled');
// Execute the command.
this.listenTo(view, 'execute', ()=>{
editor.execute('htmlEmbed');
editor.editing.view.focus();
const rawHtmlApi = editor.editing.view.document.selection.getSelectedElement().getCustomProperty('rawHtmlApi');
rawHtmlApi.makeEditable();
});
return view;
}
}
/**
* The HTML embed feature.
*
* It allows inserting HTML snippets directly into the editor.
*
* For a detailed overview, check the {@glink features/html/html-embed HTML embed feature} documentation.
*/ class HtmlEmbed extends Plugin {
/**
* @inheritDoc
*/ static get requires() {
return [
HtmlEmbedEditing,
HtmlEmbedUI,
Widget
];
}
/**
* @inheritDoc
*/ static get pluginName() {
return 'HtmlEmbed';
}
/**
* @inheritDoc
*/ static get isOfficialPlugin() {
return true;
}
}
export { HtmlEmbed, HtmlEmbedEditing, HtmlEmbedUI };
//# sourceMappingURL=index.js.map