mentionediting.js 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  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 ) );
  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 ) {
  119. const changes = doc.differ.getChanges();
  120. let wasChanged = false;
  121. for ( const change of changes ) {
  122. // Check text node on current position;
  123. if ( change.name == '$text' ) {
  124. // Check textNode where the change occurred.
  125. if ( change.type == 'insert' || change.type == 'remove' ) {
  126. const textNode = change.position.textNode;
  127. if ( !checkMentionAttributeOnNode( textNode ) ) {
  128. writer.removeAttribute( 'mention', textNode );
  129. wasChanged = true;
  130. }
  131. }
  132. // Additional check: when removing text on mention boundaries.
  133. if ( change.type == 'remove' ) {
  134. const nodeBefore = change.position.nodeBefore;
  135. if ( !checkMentionAttributeOnNode( nodeBefore ) ) {
  136. writer.removeAttribute( 'mention', nodeBefore );
  137. wasChanged = true;
  138. }
  139. const nodeAfter = change.position.nodeAfter;
  140. if ( !checkMentionAttributeOnNode( nodeAfter ) ) {
  141. writer.removeAttribute( 'mention', nodeAfter );
  142. wasChanged = true;
  143. }
  144. }
  145. }
  146. }
  147. return wasChanged;
  148. }
  149. // This post-fixer will extend attribute applied on part of a mention so a whole text node of a mention will have added attribute.
  150. //
  151. // @param {module:engine/model/writer~Writer} writer
  152. // @param {module:engine/model/document~Document} doc
  153. // @returns {Boolean} Returns true if selection was fixed.
  154. function extendAttributeOnMentionPostFixer( writer, doc ) {
  155. const changes = doc.differ.getChanges();
  156. let wasChanged = false;
  157. for ( const change of changes ) {
  158. if ( change.type === 'attribute' ) {
  159. for ( const node of getBrokenMentionsFromRange( change.range ) ) {
  160. for ( const partialMention of getFullMentionNode( node ) ) {
  161. if ( partialMention.getAttribute( change.attributeKey ) !== change.attributeNewValue ) {
  162. writer.setAttribute( change.attributeKey, change.attributeNewValue, partialMention );
  163. wasChanged = true;
  164. }
  165. }
  166. }
  167. }
  168. }
  169. return wasChanged;
  170. }
  171. // Checks if node has correct mention attribute if present.
  172. // Returns false if node is text and has mention attribute and its text does not match expected mention text.
  173. //
  174. // @param {module:engine/model/node~Node} node a node to check
  175. // @returns {Boolean}
  176. function checkMentionAttributeOnNode( node ) {
  177. if ( !node || !( node.is( 'text' ) || node.is( 'textProxy' ) ) || !node.hasAttribute( 'mention' ) ) {
  178. return true;
  179. }
  180. const text = node.data;
  181. const mention = node.getAttribute( 'mention' );
  182. const expectedText = mention._marker + mention.name;
  183. return text == expectedText;
  184. }
  185. function* getBrokenMentionsFromRange( range ) {
  186. for ( const item of range.getItems() ) {
  187. if ( !checkMentionAttributeOnNode( item ) ) {
  188. yield item;
  189. }
  190. }
  191. }
  192. function getFullMentionNode( node ) {
  193. const textNode = node.textNode;
  194. const previousSibling = textNode.previousSibling;
  195. const nextSibling = textNode.nextSibling;
  196. const mention = textNode.getAttribute( 'mention' );
  197. const nodes = [];
  198. const isPreviousPartial = !checkMentionAttributeOnNode( previousSibling );
  199. if ( isPreviousPartial ) {
  200. const previousSiblingMention = previousSibling.getAttribute( 'mention' );
  201. const sameMention = previousSiblingMention._id == mention._id;
  202. if ( sameMention ) {
  203. nodes.push( previousSibling );
  204. }
  205. }
  206. nodes.push( textNode );
  207. const isNextPartial = !checkMentionAttributeOnNode( nextSibling );
  208. if ( isNextPartial ) {
  209. const nextSiblingMention = nextSibling.getAttribute( 'mention' );
  210. const sameMention = nextSiblingMention._id == mention._id;
  211. if ( sameMention ) {
  212. nodes.push( nextSibling );
  213. }
  214. }
  215. return nodes;
  216. }