/
mentionediting.js
291 lines (238 loc) · 9.3 KB
/
mentionediting.js
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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
/**
* @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
/**
* @module mention/mentionediting
*/
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import uid from '@ckeditor/ckeditor5-utils/src/uid';
import MentionCommand from './mentioncommand';
/**
* The mention editing feature.
*
* It introduces the {@link module:mention/mentioncommand~MentionCommand command} and the `mention`
* attribute in the {@link module:engine/model/model~Model model} which renders in the {@link module:engine/view/view view}
* as a `<span class="mention" data-mention="@mention">`.
*
* @extends module:core/plugin~Plugin
*/
export default class MentionEditing extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return 'MentionEditing';
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const model = editor.model;
const doc = model.document;
// Allow the mention attribute on all text nodes.
model.schema.extend( '$text', { allowAttributes: 'mention' } );
// Upcast conversion.
editor.conversion.for( 'upcast' ).elementToAttribute( {
view: {
name: 'span',
key: 'data-mention',
classes: 'mention'
},
model: {
key: 'mention',
value: _toMentionAttribute
}
} );
// Downcast conversion.
editor.conversion.for( 'downcast' ).attributeToElement( {
model: 'mention',
view: createViewMentionElement
} );
editor.conversion.for( 'downcast' ).add( preventPartialMentionDowncast );
doc.registerPostFixer( writer => removePartialMentionPostFixer( writer, doc, model.schema ) );
doc.registerPostFixer( writer => extendAttributeOnMentionPostFixer( writer, doc ) );
doc.registerPostFixer( writer => selectionMentionAttributePostFixer( writer, doc ) );
editor.commands.add( 'mention', new MentionCommand( editor ) );
}
}
export function _addMentionAttributes( baseMentionData, data ) {
return Object.assign( { uid: uid() }, baseMentionData, data || {} );
}
/**
* Creates a mention attribute value from the provided view element and optional data.
*
* This function is exposed as
* {@link module:mention/mention~Mention#toMentionAttribute `editor.plugins.get( 'Mention' ).toMentionAttribute()`}.
*
* @protected
* @param {module:engine/view/element~Element} viewElementOrMention
* @param {String|Object} [data] Mention data to be extended.
* @returns {module:mention/mention~MentionAttribute}
*/
export function _toMentionAttribute( viewElementOrMention, data ) {
const dataMention = viewElementOrMention.getAttribute( 'data-mention' );
const textNode = viewElementOrMention.getChild( 0 );
// Do not convert empty mentions.
if ( !textNode ) {
return;
}
const baseMentionData = {
id: dataMention,
_text: textNode.data
};
return _addMentionAttributes( baseMentionData, data );
}
// A converter that blocks partial mention from being converted.
//
// This converter is registered with 'highest' priority in order to consume mention attribute before it is converted by
// any other converters. This converter only consumes partial mention - those whose `_text` attribute is not equal to text with mention
// attribute. This may happen when copying part of mention text.
//
// @param {module:engine/conversion/dwoncastdispatcher~DowncastDispatcher}
function preventPartialMentionDowncast( dispatcher ) {
dispatcher.on( 'attribute:mention', ( evt, data, conversionApi ) => {
const mention = data.attributeNewValue;
if ( !data.item.is( '$textProxy' ) || !mention ) {
return;
}
const start = data.range.start;
const textNode = start.textNode || start.nodeAfter;
if ( textNode.data != mention._text ) {
// Consume item to prevent partial mention conversion.
conversionApi.consumable.consume( data.item, evt.name );
}
}, { priority: 'highest' } );
}
// Creates a mention element from the mention data.
//
// @param {Object} mention
// @param {module:engine/view/downcastwriter~DowncastWriter} viewWriter
// @returns {module:engine/view/attributeelement~AttributeElement}
function createViewMentionElement( mention, viewWriter ) {
if ( !mention ) {
return;
}
const attributes = {
class: 'mention',
'data-mention': mention.id
};
const options = {
id: mention.uid,
priority: 20
};
return viewWriter.createAttributeElement( 'span', attributes, options );
}
// Model post-fixer that disallows typing with selection when the selection is placed after the text node with the mention attribute or
// before a text node with mention attribute.
//
// @param {module:engine/model/writer~Writer} writer
// @param {module:engine/model/document~Document} doc
// @returns {Boolean} Returns `true` if the selection was fixed.
function selectionMentionAttributePostFixer( writer, doc ) {
const selection = doc.selection;
const focus = selection.focus;
if ( selection.isCollapsed && selection.hasAttribute( 'mention' ) && shouldNotTypeWithMentionAt( focus ) ) {
writer.removeSelectionAttribute( 'mention' );
return true;
}
}
// Helper function to detect if mention attribute should be removed from selection.
// This check makes only sense if the selection has mention attribute.
//
// The mention attribute should be removed from a selection when selection focus is placed:
// a) after a text node
// b) the position is at parents start - the selection will set attributes from node after.
function shouldNotTypeWithMentionAt( position ) {
const isAtStart = position.isAtStart;
const isAfterAMention = position.nodeBefore && position.nodeBefore.is( '$text' );
return isAfterAMention || isAtStart;
}
// Model post-fixer that removes the mention attribute from the modified text node.
//
// @param {module:engine/model/writer~Writer} writer
// @param {module:engine/model/document~Document} doc
// @returns {Boolean} Returns `true` if the selection was fixed.
function removePartialMentionPostFixer( writer, doc, schema ) {
const changes = doc.differ.getChanges();
let wasChanged = false;
for ( const change of changes ) {
// Checks the text node on the current position.
const position = change.position;
if ( change.name == '$text' ) {
const nodeAfterInsertedTextNode = position.textNode && position.textNode.nextSibling;
// Checks the text node where the change occurred.
wasChanged = checkAndFix( position.textNode, writer ) || wasChanged;
// Occurs on paste inside a text node with mention.
wasChanged = checkAndFix( nodeAfterInsertedTextNode, writer ) || wasChanged;
wasChanged = checkAndFix( position.nodeBefore, writer ) || wasChanged;
wasChanged = checkAndFix( position.nodeAfter, writer ) || wasChanged;
}
// Checks text nodes in inserted elements (might occur when splitting a paragraph or pasting content inside text with mention).
if ( change.name != '$text' && change.type == 'insert' ) {
const insertedNode = position.nodeAfter;
for ( const item of writer.createRangeIn( insertedNode ).getItems() ) {
wasChanged = checkAndFix( item, writer ) || wasChanged;
}
}
// Inserted inline elements might break mention.
if ( change.type == 'insert' && schema.isInline( change.name ) ) {
const nodeAfterInserted = position.nodeAfter && position.nodeAfter.nextSibling;
wasChanged = checkAndFix( position.nodeBefore, writer ) || wasChanged;
wasChanged = checkAndFix( nodeAfterInserted, writer ) || wasChanged;
}
}
return wasChanged;
}
// This post-fixer will extend the attribute applied on the part of the mention so the whole text node of the mention will have
// the added attribute.
//
// @param {module:engine/model/writer~Writer} writer
// @param {module:engine/model/document~Document} doc
// @returns {Boolean} Returns `true` if the selection was fixed.
function extendAttributeOnMentionPostFixer( writer, doc ) {
const changes = doc.differ.getChanges();
let wasChanged = false;
for ( const change of changes ) {
if ( change.type === 'attribute' && change.attributeKey != 'mention' ) {
// Checks the node on the left side of the range...
const nodeBefore = change.range.start.nodeBefore;
// ... and on the right side of the range.
const nodeAfter = change.range.end.nodeAfter;
for ( const node of [ nodeBefore, nodeAfter ] ) {
if ( isBrokenMentionNode( node ) && node.getAttribute( change.attributeKey ) != change.attributeNewValue ) {
writer.setAttribute( change.attributeKey, change.attributeNewValue, node );
wasChanged = true;
}
}
}
}
return wasChanged;
}
// Checks if a node has a correct mention attribute if present.
// Returns `true` if the node is text and has a mention attribute whose text does not match the expected mention text.
//
// @param {module:engine/model/node~Node} node The node to check.
// @returns {Boolean}
function isBrokenMentionNode( node ) {
if ( !node || !( node.is( '$text' ) || node.is( '$textProxy' ) ) || !node.hasAttribute( 'mention' ) ) {
return false;
}
const text = node.data;
const mention = node.getAttribute( 'mention' );
const expectedText = mention._text;
return text != expectedText;
}
// Fixes a mention on a text node if it needs a fix.
//
// @param {module:engine/model/text~Text} textNode
// @param {module:engine/model/writer~Writer} writer
// @returns {Boolean}
function checkAndFix( textNode, writer ) {
if ( isBrokenMentionNode( textNode ) ) {
writer.removeAttribute( 'mention', textNode );
return true;
}
return false;
}