8
0

restrictededitingmodeediting.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  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 restricted-editing/restrictededitingmodeediting
  7. */
  8. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  9. import RestrictedEditingNavigationCommand from './restrictededitingmodenavigationcommand';
  10. import {
  11. extendMarkerOnTypingPostFixer,
  12. resurrectCollapsedMarkerPostFixer,
  13. setupExceptionHighlighting,
  14. upcastHighlightToMarker
  15. } from './restrictededitingmode/converters';
  16. import { getMarkerAtPosition, isSelectionInMarker } from './restrictededitingmode/utils';
  17. const COMMAND_FORCE_DISABLE_ID = 'RestrictedEditingMode';
  18. /**
  19. * The restricted editing mode editing feature.
  20. *
  21. * * It introduces the exception marker group that renders to `<span>` elements with the `restricted-editing-exception` CSS class.
  22. * * It registers the `'goToPreviousRestrictedEditingException'` and `'goToNextRestrictedEditingException'` commands.
  23. * * It also enables highlighting exception markers that are selected.
  24. *
  25. * @extends module:core/plugin~Plugin
  26. */
  27. export default class RestrictedEditingModeEditing extends Plugin {
  28. /**
  29. * @inheritDoc
  30. */
  31. static get pluginName() {
  32. return 'RestrictedEditingModeEditing';
  33. }
  34. /**
  35. * @inheritDoc
  36. */
  37. constructor( editor ) {
  38. super( editor );
  39. editor.config.define( 'restrictedEditing', {
  40. allowedCommands: [ 'bold', 'italic', 'link', 'unlink' ],
  41. allowedAttributes: [ 'bold', 'italic', 'linkHref' ]
  42. } );
  43. /**
  44. * Command names that are enabled outside the non-restricted regions.
  45. *
  46. * @type {Set.<String>}
  47. * @private
  48. */
  49. this._alwaysEnabled = new Set( [ 'undo', 'redo', 'goToPreviousRestrictedEditingException', 'goToNextRestrictedEditingException' ] );
  50. /**
  51. * Commands allowed in non-restricted areas.
  52. *
  53. * Commands always enabled combine typing feature commands: `'typing'`, `'delete'` and `'forwardDelete'` with commands defined
  54. * in the feature configuration.
  55. *
  56. * @type {Set<string>}
  57. * @private
  58. */
  59. this._allowedInException = new Set( [ 'input', 'delete', 'forwardDelete' ] );
  60. }
  61. /**
  62. * @inheritDoc
  63. */
  64. init() {
  65. const editor = this.editor;
  66. const editingView = editor.editing.view;
  67. const allowedCommands = editor.config.get( 'restrictedEditing.allowedCommands' );
  68. allowedCommands.forEach( commandName => this._allowedInException.add( commandName ) );
  69. this._setupConversion();
  70. this._setupCommandsToggling();
  71. this._setupRestrictions();
  72. // Commands & keystrokes that allow navigation in the content.
  73. editor.commands.add( 'goToPreviousRestrictedEditingException', new RestrictedEditingNavigationCommand( editor, 'backward' ) );
  74. editor.commands.add( 'goToNextRestrictedEditingException', new RestrictedEditingNavigationCommand( editor, 'forward' ) );
  75. editor.keystrokes.set( 'Tab', getCommandExecuter( editor, 'goToNextRestrictedEditingException' ) );
  76. editor.keystrokes.set( 'Shift+Tab', getCommandExecuter( editor, 'goToPreviousRestrictedEditingException' ) );
  77. editor.keystrokes.set( 'Ctrl+A', getSelectAllHandler( editor ) );
  78. editingView.change( writer => {
  79. for ( const root of editingView.document.roots ) {
  80. writer.addClass( 'ck-restricted-editing_mode_restricted', root );
  81. }
  82. } );
  83. }
  84. /**
  85. * Makes the given command always enabled in the restricted editing mode (regardless
  86. * of selection location).
  87. *
  88. * To enable some commands in non-restricted areas of the content use
  89. * {@link module:restricted-editing/restrictededitingmode~RestrictedEditingModeConfig#allowedCommands} configuration option.
  90. *
  91. * @param {String} commandName Name of the command to enable.
  92. */
  93. enableCommand( commandName ) {
  94. const command = this.editor.commands.get( commandName );
  95. command.clearForceDisabled( COMMAND_FORCE_DISABLE_ID );
  96. this._alwaysEnabled.add( commandName );
  97. }
  98. /**
  99. * Sets up the restricted mode editing conversion:
  100. *
  101. * * ucpast & downcast converters,
  102. * * marker highlighting in the edting area,
  103. * * marker post-fixers.
  104. *
  105. * @private
  106. */
  107. _setupConversion() {
  108. const editor = this.editor;
  109. const model = editor.model;
  110. const doc = model.document;
  111. // The restricted editing does not attach additional data to the zones so there's no need for smarter markers management.
  112. // Also, the markers will only be created when when loading the data.
  113. let markerNumber = 0;
  114. editor.conversion.for( 'upcast' ).add( upcastHighlightToMarker( {
  115. view: {
  116. name: 'span',
  117. classes: 'restricted-editing-exception'
  118. },
  119. model: () => {
  120. markerNumber++; // Starting from restrictedEditingException:1 marker.
  121. return `restrictedEditingException:${ markerNumber }`;
  122. }
  123. } ) );
  124. // Currently the marker helpers are tied to other use-cases and do not render collapsed marker as highlight.
  125. // That's why there are 2 downcast converters for them:
  126. // 1. The default marker-to-highlight will wrap selected text with `<span>`.
  127. editor.conversion.for( 'downcast' ).markerToHighlight( {
  128. model: 'restrictedEditingException',
  129. // Use callback to return new object every time new marker instance is created - otherwise it will be seen as the same marker.
  130. view: () => {
  131. return {
  132. name: 'span',
  133. classes: 'restricted-editing-exception',
  134. priority: -10
  135. };
  136. }
  137. } );
  138. // 2. But for collapsed marker we need to render it as an element.
  139. // Additionally the editing pipeline should always display a collapsed markers.
  140. editor.conversion.for( 'editingDowncast' ).markerToElement( {
  141. model: 'restrictedEditingException',
  142. view: ( markerData, viewWriter ) => {
  143. return viewWriter.createUIElement( 'span', {
  144. class: 'restricted-editing-exception restricted-editing-exception_collapsed'
  145. } );
  146. }
  147. } );
  148. editor.conversion.for( 'dataDowncast' ).markerToElement( {
  149. model: 'restrictedEditingException',
  150. view: ( markerData, viewWriter ) => {
  151. return viewWriter.createEmptyElement( 'span', {
  152. class: 'restricted-editing-exception'
  153. } );
  154. }
  155. } );
  156. doc.registerPostFixer( extendMarkerOnTypingPostFixer( editor ) );
  157. doc.registerPostFixer( resurrectCollapsedMarkerPostFixer( editor ) );
  158. model.markers.on( 'update:restrictedEditingException', ensureNewMarkerIsFlat( editor ) );
  159. setupExceptionHighlighting( editor );
  160. }
  161. /**
  162. * Setups additional editing restrictions beyond command toggling:
  163. *
  164. * * delete content range trimming
  165. * * disabling input command outside exception marker
  166. * * restricting clipboard holder to text only
  167. * * restricting text attributes in content
  168. *
  169. * @private
  170. */
  171. _setupRestrictions() {
  172. const editor = this.editor;
  173. const model = editor.model;
  174. const selection = model.document.selection;
  175. const viewDoc = editor.editing.view.document;
  176. this.listenTo( model, 'deleteContent', restrictDeleteContent( editor ), { priority: 'high' } );
  177. const inputCommand = editor.commands.get( 'input' );
  178. // The restricted editing might be configured without input support - ie allow only bolding or removing text.
  179. // This check is bit synthetic since only tests are used this way.
  180. if ( inputCommand ) {
  181. this.listenTo( inputCommand, 'execute', disallowInputExecForWrongRange( editor ), { priority: 'high' } );
  182. }
  183. // Block clipboard outside exception marker on paste.
  184. this.listenTo( viewDoc, 'clipboardInput', function( evt ) {
  185. if ( !isRangeInsideSingleMarker( editor, selection.getFirstRange() ) ) {
  186. evt.stop();
  187. }
  188. }, { priority: 'high' } );
  189. // Block clipboard outside exception marker on cut.
  190. this.listenTo( viewDoc, 'clipboardOutput', ( evt, data ) => {
  191. if ( data.method == 'cut' && !isRangeInsideSingleMarker( editor, selection.getFirstRange() ) ) {
  192. evt.stop();
  193. }
  194. }, { priority: 'high' } );
  195. const allowedAttributes = editor.config.get( 'restrictedEditing.allowedAttributes' );
  196. model.schema.addAttributeCheck( onlyAllowAttributesFromList( allowedAttributes ) );
  197. model.schema.addChildCheck( allowTextOnlyInClipboardHolder );
  198. }
  199. /**
  200. * Sets up the command toggling which enables or disables commands based on the user selection.
  201. *
  202. * @private
  203. */
  204. _setupCommandsToggling() {
  205. const editor = this.editor;
  206. const model = editor.model;
  207. const doc = model.document;
  208. this._disableCommands( editor );
  209. this.listenTo( doc.selection, 'change', this._checkCommands.bind( this ) );
  210. this.listenTo( doc, 'change:data', this._checkCommands.bind( this ) );
  211. }
  212. /**
  213. * Checks if commands should be enabled or disabled based on the current selection.
  214. *
  215. * @private
  216. */
  217. _checkCommands() {
  218. const editor = this.editor;
  219. const selection = editor.model.document.selection;
  220. if ( selection.rangeCount > 1 ) {
  221. this._disableCommands( editor );
  222. return;
  223. }
  224. const marker = getMarkerAtPosition( editor, selection.focus );
  225. this._disableCommands();
  226. if ( isSelectionInMarker( selection, marker ) ) {
  227. this._enableCommands( marker );
  228. }
  229. }
  230. /**
  231. * Enables commands in non-restricted regions.
  232. *
  233. * @returns {module:engine/model/markercollection~Marker} marker
  234. * @private
  235. */
  236. _enableCommands( marker ) {
  237. const editor = this.editor;
  238. const commands = this._getCommandNamesToToggle( editor, this._allowedInException )
  239. .filter( name => this._allowedInException.has( name ) )
  240. .filter( filterDeleteCommandsOnMarkerBoundaries( editor.model.document.selection, marker.getRange() ) )
  241. .map( name => editor.commands.get( name ) );
  242. for ( const command of commands ) {
  243. command.clearForceDisabled( COMMAND_FORCE_DISABLE_ID );
  244. }
  245. }
  246. /**
  247. * Disables commands outside non-restricted regions.
  248. *
  249. * @private
  250. */
  251. _disableCommands() {
  252. const editor = this.editor;
  253. const commands = this._getCommandNamesToToggle( editor )
  254. .map( name => editor.commands.get( name ) );
  255. for ( const command of commands ) {
  256. command.forceDisabled( COMMAND_FORCE_DISABLE_ID );
  257. }
  258. }
  259. /**
  260. * Returns command names that should be toggleable.
  261. *
  262. * @param {module:core/editor/editor~Editor} editor
  263. * @returns {Array.<String>}
  264. * @private
  265. */
  266. _getCommandNamesToToggle( editor ) {
  267. return Array.from( editor.commands.names() )
  268. .filter( name => !this._alwaysEnabled.has( name ) );
  269. }
  270. }
  271. // Helper method for executing enabled commands only.
  272. function getCommandExecuter( editor, commandName ) {
  273. return ( data, cancel ) => {
  274. const command = editor.commands.get( commandName );
  275. if ( command.isEnabled ) {
  276. editor.execute( commandName );
  277. cancel();
  278. }
  279. };
  280. }
  281. // Helper for handling Ctrl+A keydown behaviour.
  282. function getSelectAllHandler( editor ) {
  283. return ( data, cancel ) => {
  284. const model = editor.model;
  285. const selection = editor.model.document.selection;
  286. const marker = getMarkerAtPosition( editor, selection.focus );
  287. if ( !marker ) {
  288. return;
  289. }
  290. // If selection range is inside a restricted editing exception, select text only within the exception.
  291. //
  292. // Note: Second Ctrl+A press is also blocked and it won't select the entire text in the editor.
  293. const selectionRange = selection.getFirstRange();
  294. const markerRange = marker.getRange();
  295. if ( markerRange.containsRange( selectionRange, true ) || selection.isCollapsed ) {
  296. cancel();
  297. model.change( writer => {
  298. writer.setSelection( marker.getRange() );
  299. } );
  300. }
  301. };
  302. }
  303. // Additional filtering rule for enabling "delete" and "forwardDelete" commands if selection is on range boundaries:
  304. //
  305. // Does not allow to enable command when selection focus is:
  306. // - is on marker start - "delete" - to prevent removing content before marker
  307. // - is on marker end - "forwardDelete" - to prevent removing content after marker
  308. function filterDeleteCommandsOnMarkerBoundaries( selection, markerRange ) {
  309. return name => {
  310. if ( name == 'delete' && markerRange.start.isEqual( selection.focus ) ) {
  311. return false;
  312. }
  313. // Only for collapsed selection - non-collapsed selection that extends over a marker is handled elsewhere.
  314. if ( name == 'forwardDelete' && selection.isCollapsed && markerRange.end.isEqual( selection.focus ) ) {
  315. return false;
  316. }
  317. return true;
  318. };
  319. }
  320. // Ensures that model.deleteContent() does not delete outside exception markers ranges.
  321. //
  322. // The enforced restrictions are:
  323. // - only execute deleteContent() inside exception markers
  324. // - restrict passed selection to exception marker
  325. function restrictDeleteContent( editor ) {
  326. return ( evt, args ) => {
  327. const [ selection ] = args;
  328. const marker = getMarkerAtPosition( editor, selection.focus ) || getMarkerAtPosition( editor, selection.anchor );
  329. // Stop method execution if marker was not found at selection focus.
  330. if ( !marker ) {
  331. evt.stop();
  332. return;
  333. }
  334. // Collapsed selection inside exception marker does not require fixing.
  335. if ( selection.isCollapsed ) {
  336. return;
  337. }
  338. // Shrink the selection to the range inside exception marker.
  339. const allowedToDelete = marker.getRange().getIntersection( selection.getFirstRange() );
  340. // Some features uses selection passed to model.deleteContent() to set the selection afterwards. For this we need to properly modify
  341. // either the document selection using change block...
  342. if ( selection.is( 'documentSelection' ) ) {
  343. editor.model.change( writer => {
  344. writer.setSelection( allowedToDelete );
  345. } );
  346. }
  347. // ... or by modifying passed selection instance directly.
  348. else {
  349. selection.setTo( allowedToDelete );
  350. }
  351. };
  352. }
  353. // Ensures that input command is executed with a range that is inside exception marker.
  354. //
  355. // This restriction is due to fact that using native spell check changes text outside exception marker.
  356. function disallowInputExecForWrongRange( editor ) {
  357. return ( evt, args ) => {
  358. const [ options ] = args;
  359. const { range } = options;
  360. // Only check "input" command executed with a range value.
  361. // Selection might be set in exception marker but passed range might point elsewhere.
  362. if ( !range ) {
  363. return;
  364. }
  365. if ( !isRangeInsideSingleMarker( editor, range ) ) {
  366. evt.stop();
  367. }
  368. };
  369. }
  370. function isRangeInsideSingleMarker( editor, range ) {
  371. const markerAtStart = getMarkerAtPosition( editor, range.start );
  372. const markerAtEnd = getMarkerAtPosition( editor, range.end );
  373. return markerAtStart && markerAtEnd && markerAtEnd === markerAtStart;
  374. }
  375. // Checks if new marker range is flat. Non-flat ranges might appear during upcast conversion in nested structures, ie tables.
  376. //
  377. // Note: This marker fixer only consider case which is possible to create using StandardEditing mode plugin.
  378. // Markers created by developer in the data might break in many other ways.
  379. //
  380. // See #6003.
  381. function ensureNewMarkerIsFlat( editor ) {
  382. const model = editor.model;
  383. return ( evt, marker, oldRange, newRange ) => {
  384. if ( !oldRange && !newRange.isFlat ) {
  385. model.change( writer => {
  386. const start = newRange.start;
  387. const end = newRange.end;
  388. const startIsHigherInTree = start.path.length > end.path.length;
  389. const fixedStart = startIsHigherInTree ? newRange.start : writer.createPositionAt( end.parent, 0 );
  390. const fixedEnd = startIsHigherInTree ? writer.createPositionAt( start.parent, 'end' ) : newRange.end;
  391. writer.updateMarker( marker, {
  392. range: writer.createRange( fixedStart, fixedEnd )
  393. } );
  394. } );
  395. }
  396. };
  397. }
  398. function onlyAllowAttributesFromList( allowedAttributes ) {
  399. return ( context, attributeName ) => {
  400. if ( context.startsWith( '$clipboardHolder' ) ) {
  401. return allowedAttributes.includes( attributeName );
  402. }
  403. };
  404. }
  405. function allowTextOnlyInClipboardHolder( context, childDefinition ) {
  406. if ( context.startsWith( '$clipboardHolder' ) ) {
  407. return childDefinition.name === '$text';
  408. }
  409. }