-
-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Expand file tree
/
Copy pathemptyblock.ts
More file actions
187 lines (161 loc) · 5.79 KB
/
emptyblock.ts
File metadata and controls
187 lines (161 loc) · 5.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
/**
* @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-support/emptyblock
*/
import type { ClipboardContentInsertionEvent, ClipboardPipeline } from 'ckeditor5/src/clipboard.js';
import { Plugin } from 'ckeditor5/src/core.js';
import type {
UpcastElementEvent,
Element,
Schema,
DowncastDispatcher,
UpcastDispatcher,
DowncastAttributeEvent
} from 'ckeditor5/src/engine.js';
const EMPTY_BLOCK_MODEL_ATTRIBUTE = 'htmlEmptyBlock';
/**
* This is experimental plugin that allows for preserving empty block elements
* in the editor content instead of automatically filling them with block fillers (` `).
*
* 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> </p>
* <p class="spacer"> </p>
* <td> </td>
* ```
*
* **Warning**: This is an experimental plugin. It may have bugs and breaking changes may be introduced without prior notice.
*/
export default class EmptyBlock extends Plugin {
/**
* @inheritDoc
*/
public static get pluginName() {
return 'EmptyBlock' as const;
}
/**
* @inheritDoc
*/
public static override get isOfficialPlugin(): true {
return true;
}
/**
* @inheritDoc
*/
public afterInit(): void {
const { model, conversion, plugins, config } = this.editor;
const schema = model.schema;
const preserveInEditingView = config.get( 'htmlSupport.emptyBlock.preserveInEditingView' );
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 ( preserveInEditingView ) {
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 ` ` filler is added, so the getData result is `<p> </p>`.
* * When content is pasted from the same editor instance with `<p></p>`,
* the ` ` filler is not added, so the getData result is `<p></p>`.
*/
private _registerClipboardPastingHandler() {
const clipboardPipeline: ClipboardPipeline = this.editor.plugins.get( 'ClipboardPipeline' );
this.listenTo<ClipboardContentInsertionEvent>( 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: DowncastDispatcher ) => {
dispatcher.on<DowncastAttributeEvent<Element>>( `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 as Element );
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: Schema ) {
return ( dispatcher: UpcastDispatcher ) => {
dispatcher.on<UpcastElementEvent>( '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 as Element;
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' } );
};
}