UNPKG

@ckeditor/ckeditor5-html-support

Version:

HTML Support feature for CKEditor 5.

147 lines (146 loc) 5.78 kB
/** * @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'; const EMPTY_BLOCK_MODEL_ATTRIBUTE = 'htmlEmptyBlock'; /** * This plugin allows for preserving empty block elements in the editor content * instead of automatically filling them with block fillers (`&nbsp;`). * * This is useful when you want to: * * * Preserve empty block elements exactly as they were in the source HTML. * * Allow for styling empty blocks with CSS (block fillers can interfere with height/margin). * * Maintain compatibility with external systems that expect empty blocks to remain empty. * * Known limitations: * * * Empty blocks may not work correctly with revision history features. * * Keyboard navigation through the document might behave unexpectedly, especially when * navigating through structures like lists and tables. * * For example, this allows for HTML like: * * ```html * <p></p> * <p class="spacer"></p> * <td></td> * ``` * to remain empty instead of being converted to: * * ```html * <p>&nbsp;</p> * <p class="spacer">&nbsp;</p> * <td>&nbsp;</td> * ``` */ export class EmptyBlock extends Plugin { /** * @inheritDoc */ static get pluginName() { return 'EmptyBlock'; } /** * @inheritDoc */ static get isOfficialPlugin() { return true; } /** * @inheritDoc */ afterInit() { const { model, conversion, plugins, config } = this.editor; const schema = model.schema; const preserveEmptyBlocksInEditingView = config.get('htmlSupport.preserveEmptyBlocksInEditingView'); schema.extend('$block', { allowAttributes: [EMPTY_BLOCK_MODEL_ATTRIBUTE] }); schema.extend('$container', { allowAttributes: [EMPTY_BLOCK_MODEL_ATTRIBUTE] }); if (schema.isRegistered('tableCell')) { schema.extend('tableCell', { allowAttributes: [EMPTY_BLOCK_MODEL_ATTRIBUTE] }); } if (preserveEmptyBlocksInEditingView) { conversion.for('downcast').add(createEmptyBlockDowncastConverter()); } else { conversion.for('dataDowncast').add(createEmptyBlockDowncastConverter()); } conversion.for('upcast').add(createEmptyBlockUpcastConverter(schema)); if (plugins.has('ClipboardPipeline')) { this._registerClipboardPastingHandler(); } } /** * Handle clipboard paste events: * * * It does not affect *copying* content from the editor, only *pasting*. * * When content is pasted from another editor instance with `<p></p>`, * the `&nbsp;` filler is added, so the getData result is `<p>&nbsp;</p>`. * * When content is pasted from the same editor instance with `<p></p>`, * the `&nbsp;` filler is not added, so the getData result is `<p></p>`. */ _registerClipboardPastingHandler() { const clipboardPipeline = this.editor.plugins.get('ClipboardPipeline'); this.listenTo(clipboardPipeline, 'contentInsertion', (evt, data) => { if (data.sourceEditorId === this.editor.id) { return; } this.editor.model.change(writer => { for (const { item } of writer.createRangeIn(data.content)) { if (item.is('element') && item.hasAttribute(EMPTY_BLOCK_MODEL_ATTRIBUTE)) { writer.removeAttribute(EMPTY_BLOCK_MODEL_ATTRIBUTE, item); } } }); }); } } /** * Creates a downcast converter for handling empty blocks. * This converter prevents filler elements from being added to elements marked as empty blocks. */ function createEmptyBlockDowncastConverter() { return (dispatcher) => { dispatcher.on(`attribute:${EMPTY_BLOCK_MODEL_ATTRIBUTE}`, (evt, data, conversionApi) => { const { mapper, consumable } = conversionApi; const { item } = data; if (!consumable.consume(item, evt.name)) { return; } const viewElement = mapper.toViewElement(item); if (viewElement && data.attributeNewValue) { viewElement.getFillerOffset = () => null; } }); }; } /** * Creates an upcast converter for handling empty blocks. * The converter detects empty elements and marks them with the empty block attribute. */ function createEmptyBlockUpcastConverter(schema) { return (dispatcher) => { dispatcher.on('element', (evt, data, conversionApi) => { const { viewItem, modelRange } = data; if (!viewItem.is('element') || !viewItem.isEmpty || viewItem.getCustomProperty('$hasBlockFiller')) { return; } // Handle element itself. const modelElement = modelRange && modelRange.start.nodeAfter; if (!modelElement || !schema.checkAttribute(modelElement, EMPTY_BLOCK_MODEL_ATTRIBUTE)) { return; } conversionApi.writer.setAttribute(EMPTY_BLOCK_MODEL_ATTRIBUTE, true, modelElement); // Handle an auto-paragraphed bogus paragraph inside empty element. if (modelElement.childCount != 1) { return; } const firstModelChild = modelElement.getChild(0); if (firstModelChild.is('element', 'paragraph') && schema.checkAttribute(firstModelChild, EMPTY_BLOCK_MODEL_ATTRIBUTE)) { conversionApi.writer.setAttribute(EMPTY_BLOCK_MODEL_ATTRIBUTE, true, firstModelChild); } }, { priority: 'lowest' }); }; }