restrictededitingmodeediting.js 14 KB

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