@ckeditor/ckeditor5-mention 30.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,291 @@
1
+ /**
2
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
3
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4
+ */
5
+
6
+ /**
7
+ * @module mention/mentionediting
8
+ */
9
+
10
+ import { Plugin } from 'ckeditor5/src/core';
11
+ import { uid } from 'ckeditor5/src/utils';
12
+
13
+ import MentionCommand from './mentioncommand';
14
+
15
+ /**
16
+ * The mention editing feature.
17
+ *
18
+ * It introduces the {@link module:mention/mentioncommand~MentionCommand command} and the `mention`
19
+ * attribute in the {@link module:engine/model/model~Model model} which renders in the {@link module:engine/view/view view}
20
+ * as a `<span class="mention" data-mention="@mention">`.
21
+ *
22
+ * @extends module:core/plugin~Plugin
23
+ */
24
+ export default class MentionEditing extends Plugin {
25
+ /**
26
+ * @inheritDoc
27
+ */
28
+ static get pluginName() {
29
+ return 'MentionEditing';
30
+ }
31
+
32
+ /**
33
+ * @inheritDoc
34
+ */
35
+ init() {
36
+ const editor = this.editor;
37
+ const model = editor.model;
38
+ const doc = model.document;
39
+
40
+ // Allow the mention attribute on all text nodes.
41
+ model.schema.extend( '$text', { allowAttributes: 'mention' } );
42
+
43
+ // Upcast conversion.
44
+ editor.conversion.for( 'upcast' ).elementToAttribute( {
45
+ view: {
46
+ name: 'span',
47
+ key: 'data-mention',
48
+ classes: 'mention'
49
+ },
50
+ model: {
51
+ key: 'mention',
52
+ value: viewElement => _toMentionAttribute( viewElement )
53
+ }
54
+ } );
55
+
56
+ // Downcast conversion.
57
+ editor.conversion.for( 'downcast' ).attributeToElement( {
58
+ model: 'mention',
59
+ view: createViewMentionElement
60
+ } );
61
+ editor.conversion.for( 'downcast' ).add( preventPartialMentionDowncast );
62
+
63
+ doc.registerPostFixer( writer => removePartialMentionPostFixer( writer, doc, model.schema ) );
64
+ doc.registerPostFixer( writer => extendAttributeOnMentionPostFixer( writer, doc ) );
65
+ doc.registerPostFixer( writer => selectionMentionAttributePostFixer( writer, doc ) );
66
+
67
+ editor.commands.add( 'mention', new MentionCommand( editor ) );
68
+ }
69
+ }
70
+
71
+ export function _addMentionAttributes( baseMentionData, data ) {
72
+ return Object.assign( { uid: uid() }, baseMentionData, data || {} );
73
+ }
74
+
75
+ /**
76
+ * Creates a mention attribute value from the provided view element and optional data.
77
+ *
78
+ * This function is exposed as
79
+ * {@link module:mention/mention~Mention#toMentionAttribute `editor.plugins.get( 'Mention' ).toMentionAttribute()`}.
80
+ *
81
+ * @protected
82
+ * @param {module:engine/view/element~Element} viewElementOrMention
83
+ * @param {String|Object} [data] Mention data to be extended.
84
+ * @returns {module:mention/mention~MentionAttribute}
85
+ */
86
+ export function _toMentionAttribute( viewElementOrMention, data ) {
87
+ const dataMention = viewElementOrMention.getAttribute( 'data-mention' );
88
+
89
+ const textNode = viewElementOrMention.getChild( 0 );
90
+
91
+ // Do not convert empty mentions.
92
+ if ( !textNode ) {
93
+ return;
94
+ }
95
+
96
+ const baseMentionData = {
97
+ id: dataMention,
98
+ _text: textNode.data
99
+ };
100
+
101
+ return _addMentionAttributes( baseMentionData, data );
102
+ }
103
+
104
+ // A converter that blocks partial mention from being converted.
105
+ //
106
+ // This converter is registered with 'highest' priority in order to consume mention attribute before it is converted by
107
+ // any other converters. This converter only consumes partial mention - those whose `_text` attribute is not equal to text with mention
108
+ // attribute. This may happen when copying part of mention text.
109
+ //
110
+ // @param {module:engine/conversion/dwoncastdispatcher~DowncastDispatcher}
111
+ function preventPartialMentionDowncast( dispatcher ) {
112
+ dispatcher.on( 'attribute:mention', ( evt, data, conversionApi ) => {
113
+ const mention = data.attributeNewValue;
114
+
115
+ if ( !data.item.is( '$textProxy' ) || !mention ) {
116
+ return;
117
+ }
118
+
119
+ const start = data.range.start;
120
+ const textNode = start.textNode || start.nodeAfter;
121
+
122
+ if ( textNode.data != mention._text ) {
123
+ // Consume item to prevent partial mention conversion.
124
+ conversionApi.consumable.consume( data.item, evt.name );
125
+ }
126
+ }, { priority: 'highest' } );
127
+ }
128
+
129
+ // Creates a mention element from the mention data.
130
+ //
131
+ // @param {Object} mention
132
+ // @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
133
+ // @returns {module:engine/view/attributeelement~AttributeElement}
134
+ function createViewMentionElement( mention, { writer } ) {
135
+ if ( !mention ) {
136
+ return;
137
+ }
138
+
139
+ const attributes = {
140
+ class: 'mention',
141
+ 'data-mention': mention.id
142
+ };
143
+
144
+ const options = {
145
+ id: mention.uid,
146
+ priority: 20
147
+ };
148
+
149
+ return writer.createAttributeElement( 'span', attributes, options );
150
+ }
151
+
152
+ // Model post-fixer that disallows typing with selection when the selection is placed after the text node with the mention attribute or
153
+ // before a text node with mention attribute.
154
+ //
155
+ // @param {module:engine/model/writer~Writer} writer
156
+ // @param {module:engine/model/document~Document} doc
157
+ // @returns {Boolean} Returns `true` if the selection was fixed.
158
+ function selectionMentionAttributePostFixer( writer, doc ) {
159
+ const selection = doc.selection;
160
+ const focus = selection.focus;
161
+
162
+ if ( selection.isCollapsed && selection.hasAttribute( 'mention' ) && shouldNotTypeWithMentionAt( focus ) ) {
163
+ writer.removeSelectionAttribute( 'mention' );
164
+
165
+ return true;
166
+ }
167
+ }
168
+
169
+ // Helper function to detect if mention attribute should be removed from selection.
170
+ // This check makes only sense if the selection has mention attribute.
171
+ //
172
+ // The mention attribute should be removed from a selection when selection focus is placed:
173
+ // a) after a text node
174
+ // b) the position is at parents start - the selection will set attributes from node after.
175
+ function shouldNotTypeWithMentionAt( position ) {
176
+ const isAtStart = position.isAtStart;
177
+ const isAfterAMention = position.nodeBefore && position.nodeBefore.is( '$text' );
178
+
179
+ return isAfterAMention || isAtStart;
180
+ }
181
+
182
+ // Model post-fixer that removes the mention attribute from the modified text node.
183
+ //
184
+ // @param {module:engine/model/writer~Writer} writer
185
+ // @param {module:engine/model/document~Document} doc
186
+ // @returns {Boolean} Returns `true` if the selection was fixed.
187
+ function removePartialMentionPostFixer( writer, doc, schema ) {
188
+ const changes = doc.differ.getChanges();
189
+
190
+ let wasChanged = false;
191
+
192
+ for ( const change of changes ) {
193
+ // Checks the text node on the current position.
194
+ const position = change.position;
195
+
196
+ if ( change.name == '$text' ) {
197
+ const nodeAfterInsertedTextNode = position.textNode && position.textNode.nextSibling;
198
+
199
+ // Checks the text node where the change occurred.
200
+ wasChanged = checkAndFix( position.textNode, writer ) || wasChanged;
201
+
202
+ // Occurs on paste inside a text node with mention.
203
+ wasChanged = checkAndFix( nodeAfterInsertedTextNode, writer ) || wasChanged;
204
+ wasChanged = checkAndFix( position.nodeBefore, writer ) || wasChanged;
205
+ wasChanged = checkAndFix( position.nodeAfter, writer ) || wasChanged;
206
+ }
207
+
208
+ // Checks text nodes in inserted elements (might occur when splitting a paragraph or pasting content inside text with mention).
209
+ if ( change.name != '$text' && change.type == 'insert' ) {
210
+ const insertedNode = position.nodeAfter;
211
+
212
+ for ( const item of writer.createRangeIn( insertedNode ).getItems() ) {
213
+ wasChanged = checkAndFix( item, writer ) || wasChanged;
214
+ }
215
+ }
216
+
217
+ // Inserted inline elements might break mention.
218
+ if ( change.type == 'insert' && schema.isInline( change.name ) ) {
219
+ const nodeAfterInserted = position.nodeAfter && position.nodeAfter.nextSibling;
220
+
221
+ wasChanged = checkAndFix( position.nodeBefore, writer ) || wasChanged;
222
+ wasChanged = checkAndFix( nodeAfterInserted, writer ) || wasChanged;
223
+ }
224
+ }
225
+
226
+ return wasChanged;
227
+ }
228
+
229
+ // This post-fixer will extend the attribute applied on the part of the mention so the whole text node of the mention will have
230
+ // the added attribute.
231
+ //
232
+ // @param {module:engine/model/writer~Writer} writer
233
+ // @param {module:engine/model/document~Document} doc
234
+ // @returns {Boolean} Returns `true` if the selection was fixed.
235
+ function extendAttributeOnMentionPostFixer( writer, doc ) {
236
+ const changes = doc.differ.getChanges();
237
+
238
+ let wasChanged = false;
239
+
240
+ for ( const change of changes ) {
241
+ if ( change.type === 'attribute' && change.attributeKey != 'mention' ) {
242
+ // Checks the node on the left side of the range...
243
+ const nodeBefore = change.range.start.nodeBefore;
244
+ // ... and on the right side of the range.
245
+ const nodeAfter = change.range.end.nodeAfter;
246
+
247
+ for ( const node of [ nodeBefore, nodeAfter ] ) {
248
+ if ( isBrokenMentionNode( node ) && node.getAttribute( change.attributeKey ) != change.attributeNewValue ) {
249
+ writer.setAttribute( change.attributeKey, change.attributeNewValue, node );
250
+
251
+ wasChanged = true;
252
+ }
253
+ }
254
+ }
255
+ }
256
+
257
+ return wasChanged;
258
+ }
259
+
260
+ // Checks if a node has a correct mention attribute if present.
261
+ // Returns `true` if the node is text and has a mention attribute whose text does not match the expected mention text.
262
+ //
263
+ // @param {module:engine/model/node~Node} node The node to check.
264
+ // @returns {Boolean}
265
+ function isBrokenMentionNode( node ) {
266
+ if ( !node || !( node.is( '$text' ) || node.is( '$textProxy' ) ) || !node.hasAttribute( 'mention' ) ) {
267
+ return false;
268
+ }
269
+
270
+ const text = node.data;
271
+ const mention = node.getAttribute( 'mention' );
272
+
273
+ const expectedText = mention._text;
274
+
275
+ return text != expectedText;
276
+ }
277
+
278
+ // Fixes a mention on a text node if it needs a fix.
279
+ //
280
+ // @param {module:engine/model/text~Text} textNode
281
+ // @param {module:engine/model/writer~Writer} writer
282
+ // @returns {Boolean}
283
+ function checkAndFix( textNode, writer ) {
284
+ if ( isBrokenMentionNode( textNode ) ) {
285
+ writer.removeAttribute( 'mention', textNode );
286
+
287
+ return true;
288
+ }
289
+
290
+ return false;
291
+ }