mentionediting.js 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. /**
  2. * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  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="name">`.
  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 mention attribute on all text nodes.
  35. model.schema.extend( '$text', { allowAttributes: 'mention' } );
  36. editor.conversion.for( 'upcast' ).elementToAttribute( {
  37. view: {
  38. name: 'span',
  39. key: 'data-mention',
  40. classes: 'mention'
  41. },
  42. model: {
  43. key: 'mention',
  44. value: parseMentionViewItemAttributes
  45. }
  46. } );
  47. editor.conversion.for( 'downcast' ).attributeToElement( {
  48. model: 'mention',
  49. view: createViewMentionElement
  50. } );
  51. doc.registerPostFixer( writer => removePartialMentionPostFixer( writer, doc, model.schema ) );
  52. doc.registerPostFixer( writer => extendAttributeOnMentionPostFixer( writer, doc ) );
  53. doc.registerPostFixer( writer => selectionMentionAttributePostFixer( writer, doc ) );
  54. editor.commands.add( 'mention', new MentionCommand( editor ) );
  55. }
  56. }
  57. // Parses matched view element to mention attribute value.
  58. //
  59. // @param {module:engine/view/element} viewElement
  60. // @returns {Object} Mention attribute value
  61. function parseMentionViewItemAttributes( viewElement ) {
  62. const dataMention = viewElement.getAttribute( 'data-mention' );
  63. const textNode = viewElement.getChild( 0 );
  64. // Do not parse empty mentions.
  65. if ( !textNode || !textNode.is( 'text' ) ) {
  66. return;
  67. }
  68. const mentionString = textNode.data;
  69. // Assume that mention is set as marker + mention name.
  70. const marker = mentionString.slice( 0, 1 );
  71. const name = mentionString.slice( 1 );
  72. // Do not upcast partial mentions - might come from copy-paste of partially selected mention.
  73. if ( name != dataMention ) {
  74. return;
  75. }
  76. // Set UID for mention to not merge mentions in the same block that are next to each other.
  77. return { name: dataMention, _marker: marker, _id: uid() };
  78. }
  79. // Creates mention element from mention data.
  80. //
  81. // @param {Object} mention
  82. // @param {module:engine/view/downcastwriter~DowncastWriter} viewWriter
  83. // @returns {module:engine/view/attributeelement~AttributeElement}
  84. function createViewMentionElement( mention, viewWriter ) {
  85. if ( !mention ) {
  86. return;
  87. }
  88. const attributes = {
  89. class: 'mention',
  90. 'data-mention': mention.name
  91. };
  92. const options = {
  93. id: mention._id
  94. };
  95. return viewWriter.createAttributeElement( 'span', attributes, options );
  96. }
  97. // Model post-fixer that disallows typing with selection when selection is placed after the text node with mention attribute.
  98. //
  99. // @param {module:engine/model/writer~Writer} writer
  100. // @param {module:engine/model/document~Document} doc
  101. // @returns {Boolean} Returns true if selection was fixed.
  102. function selectionMentionAttributePostFixer( writer, doc ) {
  103. const selection = doc.selection;
  104. const focus = selection.focus;
  105. if ( selection.isCollapsed && selection.hasAttribute( 'mention' ) && isNodeBeforeAText( focus ) ) {
  106. writer.removeSelectionAttribute( 'mention' );
  107. return true;
  108. }
  109. function isNodeBeforeAText( position ) {
  110. return position.nodeBefore && position.nodeBefore.is( 'text' );
  111. }
  112. }
  113. // Model post-fixer that removes mention attribute from modified text node.
  114. //
  115. // @param {module:engine/model/writer~Writer} writer
  116. // @param {module:engine/model/document~Document} doc
  117. // @returns {Boolean} Returns true if selection was fixed.
  118. function removePartialMentionPostFixer( writer, doc, schema ) {
  119. const changes = doc.differ.getChanges();
  120. let wasChanged = false;
  121. if ( !changes.length ) {
  122. return;
  123. }
  124. for ( const change of changes ) {
  125. // Check text node on current position;
  126. const position = change.position;
  127. if ( change.name == '$text' ) {
  128. const nodeAfterInsertedTextNode = position.textNode && position.textNode.nextSibling;
  129. // Check textNode where the change occurred.
  130. checkAndFix( position.textNode );
  131. // Occurs on paste occurs inside a text node with mention.
  132. checkAndFix( nodeAfterInsertedTextNode );
  133. checkAndFix( position.nodeBefore );
  134. checkAndFix( position.nodeAfter );
  135. }
  136. // Check text nodes in inserted elements (might occur when splitting paragraph or pasting content).
  137. if ( change.name != '$text' && change.type == 'insert' && schema.checkChild( change.name, '$text' ) ) {
  138. const insertedNode = position.nodeAfter;
  139. for ( const child of insertedNode.getChildren() ) {
  140. checkAndFix( child );
  141. }
  142. }
  143. // Inserted inline elements might break mention.
  144. if ( change.type == 'insert' && schema.isInline( change.name ) ) {
  145. const nodeAfterInserted = position.nodeAfter && position.nodeAfter.nextSibling;
  146. checkAndFix( position.nodeBefore );
  147. checkAndFix( nodeAfterInserted );
  148. }
  149. }
  150. function checkAndFix( textNode ) {
  151. if ( isBrokenMentionNode( textNode ) ) {
  152. writer.removeAttribute( 'mention', textNode );
  153. wasChanged = true;
  154. }
  155. }
  156. return wasChanged;
  157. }
  158. // This post-fixer will extend attribute applied on part of a mention so a whole text node of a mention will have added attribute.
  159. //
  160. // @param {module:engine/model/writer~Writer} writer
  161. // @param {module:engine/model/document~Document} doc
  162. // @returns {Boolean} Returns true if selection was fixed.
  163. function extendAttributeOnMentionPostFixer( writer, doc ) {
  164. const changes = doc.differ.getChanges();
  165. let wasChanged = false;
  166. for ( const change of changes ) {
  167. if ( change.type === 'attribute' && change.attributeKey != 'mention' ) {
  168. // Check all mentions in changed range - attribute change may span over multiple text node.
  169. for ( const textNode of getBrokenMentionsFromRange( change.range ) ) {
  170. if ( textNode.getAttribute( change.attributeKey ) !== change.attributeNewValue ) {
  171. writer.setAttribute( change.attributeKey, change.attributeNewValue, textNode );
  172. wasChanged = true;
  173. }
  174. }
  175. }
  176. }
  177. return wasChanged;
  178. }
  179. // Checks if node has correct mention attribute if present.
  180. // Returns true if node is text and has a mention attribute which text does not match expected mention text.
  181. //
  182. // @param {module:engine/model/node~Node} node a node to check
  183. // @returns {Boolean}
  184. function isBrokenMentionNode( node ) {
  185. if ( !node || !( node.is( 'text' ) || node.is( 'textProxy' ) ) || !node.hasAttribute( 'mention' ) ) {
  186. return false;
  187. }
  188. const text = node.data;
  189. const mention = node.getAttribute( 'mention' );
  190. const expectedText = mention._marker + mention.name;
  191. return text != expectedText;
  192. }
  193. // Yields all text nodes with broken mentions from a range - even if mention sticks out of the range boundary.
  194. //
  195. // @param {module:engine/range~Range} range
  196. function* getBrokenMentionsFromRange( range ) {
  197. // Check node at the left side of a range.
  198. if ( isBrokenMentionNode( range.start.nodeBefore ) ) {
  199. yield range.start.nodeBefore;
  200. }
  201. // Yield text nodes with broken mention from the range.
  202. for ( const textProxy of range.getItems() ) {
  203. if ( isBrokenMentionNode( textProxy ) ) {
  204. yield textProxy.textNode;
  205. }
  206. }
  207. // Check node at the right side of a range.
  208. if ( isBrokenMentionNode( range.end.nodeAfter ) ) {
  209. yield range.end.nodeAfter;
  210. }
  211. }