8
0

mentionediting.js 9.3 KB

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