| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463 |
- /**
- * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
- */
- /**
- * @module restricted-editing/restrictededitingmodeediting
- */
- import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
- import RestrictedEditingNavigationCommand from './restrictededitingmodenavigationcommand';
- import {
- extendMarkerOnTypingPostFixer,
- resurrectCollapsedMarkerPostFixer,
- setupExceptionHighlighting,
- upcastHighlightToMarker
- } from './restrictededitingmode/converters';
- import { getMarkerAtPosition, isSelectionInMarker } from './restrictededitingmode/utils';
- import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
- const COMMAND_FORCE_DISABLE_ID = 'RestrictedEditingMode';
- /**
- * The Restricted Editing Mode editing feature.
- *
- * * It introduces the exception marker group that renders to `<spans>` with the `restricted-editing-exception` CSS class.
- * * It registers the `'goToPreviousRestrictedEditingException'` and `'goToNextRestrictedEditingException'` commands.
- * * Also enables highlighting exception markers that are selected.
- *
- * @extends module:core/plugin~Plugin
- */
- export default class RestrictedEditingModeEditing extends Plugin {
- /**
- * @inheritDoc
- */
- static get pluginName() {
- return 'RestrictedEditingModeEditing';
- }
- /**
- * @inheritDoc
- */
- constructor( editor ) {
- super( editor );
- editor.config.define( 'restrictedEditing', {
- allowedCommands: [ 'bold', 'italic', 'link', 'unlink' ],
- allowedAttributes: [ 'bold', 'italic', 'linkHref' ]
- } );
- /**
- * Command names that are enabled outside non-restricted regions.
- *
- * @type {Set.<String>}
- * @private
- */
- this._alwaysEnabled = new Set( [ 'undo', 'redo', 'goToPreviousRestrictedEditingException', 'goToNextRestrictedEditingException' ] );
- /**
- * Commands allowed in non-restricted areas.
- *
- * Commands always enabled combines typing feature commands: `'typing'`, `'delete'` and `'forwardDelete'` with commands defined
- * in the feature configuration.
- *
- * @type {Set<string>}
- * @private
- */
- this._allowedInException = new Set( [ 'input', 'delete', 'forwardDelete' ] );
- }
- /**
- * @inheritDoc
- */
- init() {
- const editor = this.editor;
- const editingView = editor.editing.view;
- const allowedCommands = editor.config.get( 'restrictedEditing.allowedCommands' );
- allowedCommands.forEach( commandName => this._allowedInException.add( commandName ) );
- this._setupConversion();
- this._setupCommandsToggling();
- this._setupRestrictions();
- // Commands & keystrokes that allow navigation in the content.
- editor.commands.add( 'goToPreviousRestrictedEditingException', new RestrictedEditingNavigationCommand( editor, 'backward' ) );
- editor.commands.add( 'goToNextRestrictedEditingException', new RestrictedEditingNavigationCommand( editor, 'forward' ) );
- editor.keystrokes.set( 'Tab', getCommandExecuter( editor, 'goToNextRestrictedEditingException' ) );
- editor.keystrokes.set( 'Shift+Tab', getCommandExecuter( editor, 'goToPreviousRestrictedEditingException' ) );
- editingView.change( writer => {
- for ( const root of editingView.document.roots ) {
- writer.addClass( 'ck-restricted-editing_mode_restricted', root );
- }
- } );
- }
- /**
- * Enables command with given `commandName` in restricted editing mode.
- *
- * @param {String} commandName Name of the command to enable.
- */
- enableCommand( commandName ) {
- const command = this.editor.commands.get( commandName );
- if ( !command ) {
- /**
- * Trying to enable command that does not exist.
- *
- * @error restricted-editing-command-not-found
- */
- throw new CKEditorError( 'restricted-editing-command-not-found: Trying to enable command that does not exist.', this );
- }
- command.clearForceDisabled( COMMAND_FORCE_DISABLE_ID );
- this._alwaysEnabled.add( commandName );
- }
- /**
- * Setups restricted mode editing conversion:
- *
- * * ucpast & downcast converters
- * * marker highlighting in the edting area
- * * marker post-fixers
- *
- * @private
- */
- _setupConversion() {
- const editor = this.editor;
- const model = editor.model;
- const doc = model.document;
- // The restricted editing does not attach additional data to the zones so there's no need for smarter markers management.
- // Also, the markers will only be created when when loading the data.
- let markerNumber = 0;
- editor.conversion.for( 'upcast' ).add( upcastHighlightToMarker( {
- view: {
- name: 'span',
- classes: 'restricted-editing-exception'
- },
- model: () => {
- markerNumber++; // Starting from restrictedEditingException:1 marker.
- return `restrictedEditingException:${ markerNumber }`;
- }
- } ) );
- // Currently the marker helpers are tied to other use-cases and do not render collapsed marker as highlight.
- // That's why there are 2 downcast converters for them:
- // 1. The default marker-to-highlight will wrap selected text with `<span>`.
- editor.conversion.for( 'downcast' ).markerToHighlight( {
- model: 'restrictedEditingException',
- // Use callback to return new object every time new marker instance is created - otherwise it will be seen as the same marker.
- view: () => {
- return {
- name: 'span',
- classes: 'restricted-editing-exception',
- priority: -10
- };
- }
- } );
- // 2. But for collapsed marker we need to render it as an element.
- // Additionally the editing pipeline should always display a collapsed markers.
- editor.conversion.for( 'editingDowncast' ).markerToElement( {
- model: 'restrictedEditingException',
- view: ( markerData, viewWriter ) => {
- return viewWriter.createUIElement( 'span', {
- class: 'restricted-editing-exception restricted-editing-exception_collapsed'
- } );
- }
- } );
- editor.conversion.for( 'dataDowncast' ).markerToElement( {
- model: 'restrictedEditingException',
- view: ( markerData, viewWriter ) => {
- return viewWriter.createEmptyElement( 'span', {
- class: 'restricted-editing-exception'
- } );
- }
- } );
- doc.registerPostFixer( extendMarkerOnTypingPostFixer( editor ) );
- doc.registerPostFixer( resurrectCollapsedMarkerPostFixer( editor ) );
- model.markers.on( 'update:restrictedEditingException', ensureNewMarkerIsFlat( editor ) );
- setupExceptionHighlighting( editor );
- }
- /**
- * Setups additional editing restrictions beyond command toggling:
- *
- * * delete content range trimming
- * * disabling input command outside exception marker
- * * restricting clipboard holder to text only
- * * restricting text attributes in content
- *
- * @private
- */
- _setupRestrictions() {
- const editor = this.editor;
- const model = editor.model;
- const selection = model.document.selection;
- const viewDoc = editor.editing.view.document;
- this.listenTo( model, 'deleteContent', restrictDeleteContent( editor ), { priority: 'high' } );
- const inputCommand = editor.commands.get( 'input' );
- // The restricted editing might be configured without input support - ie allow only bolding or removing text.
- // This check is bit synthetic since only tests are used this way.
- if ( inputCommand ) {
- this.listenTo( inputCommand, 'execute', disallowInputExecForWrongRange( editor ), { priority: 'high' } );
- }
- // Block clipboard outside exception marker on paste.
- this.listenTo( viewDoc, 'clipboardInput', function( evt ) {
- if ( !isRangeInsideSingleMarker( editor, selection.getFirstRange() ) ) {
- evt.stop();
- }
- }, { priority: 'high' } );
- // Block clipboard outside exception marker on cut.
- this.listenTo( viewDoc, 'clipboardOutput', ( evt, data ) => {
- if ( data.method == 'cut' && !isRangeInsideSingleMarker( editor, selection.getFirstRange() ) ) {
- evt.stop();
- }
- }, { priority: 'high' } );
- const allowedAttributes = editor.config.get( 'restrictedEditing.allowedAttributes' );
- model.schema.addAttributeCheck( onlyAllowAttributesFromList( allowedAttributes ) );
- model.schema.addChildCheck( allowTextOnlyInClipboardHolder );
- }
- /**
- * Setups the commands toggling - enables or disables commands based on user selection.
- *
- * @private
- */
- _setupCommandsToggling() {
- const editor = this.editor;
- const model = editor.model;
- const doc = model.document;
- this._disableCommands( editor );
- this.listenTo( doc.selection, 'change', this._checkCommands.bind( this ) );
- this.listenTo( doc, 'change:data', this._checkCommands.bind( this ) );
- }
- /**
- * Checks if commands should be enabled or disabled based on current selection.
- *
- * @private
- */
- _checkCommands() {
- const editor = this.editor;
- const selection = editor.model.document.selection;
- if ( selection.rangeCount > 1 ) {
- this._disableCommands( editor );
- return;
- }
- const marker = getMarkerAtPosition( editor, selection.focus );
- this._disableCommands();
- if ( isSelectionInMarker( selection, marker ) ) {
- this._enableCommands( marker );
- }
- }
- /**
- * Enables commands in non-restricted regions.
- *
- * @returns {module:engine/model/markercollection~Marker} marker
- * @private
- */
- _enableCommands( marker ) {
- const editor = this.editor;
- const commands = this._getCommandNamesToToggle( editor, this._allowedInException )
- .filter( name => this._allowedInException.has( name ) )
- .filter( filterDeleteCommandsOnMarkerBoundaries( editor.model.document.selection, marker.getRange() ) )
- .map( name => editor.commands.get( name ) );
- for ( const command of commands ) {
- command.clearForceDisabled( COMMAND_FORCE_DISABLE_ID );
- }
- }
- /**
- * Disables commands outside non-restricted regions.
- *
- * @private
- */
- _disableCommands() {
- const editor = this.editor;
- const commands = this._getCommandNamesToToggle( editor )
- .map( name => editor.commands.get( name ) );
- for ( const command of commands ) {
- command.forceDisabled( COMMAND_FORCE_DISABLE_ID );
- }
- }
- /**
- * Returns command names that should be toggleable.
- *
- * @param {module:core/editor/editor~Editor} editor
- * @returns {Array.<String>}
- * @private
- */
- _getCommandNamesToToggle( editor ) {
- return Array.from( editor.commands.names() )
- .filter( name => !this._alwaysEnabled.has( name ) );
- }
- }
- // Helper method for executing enabled commands only.
- function getCommandExecuter( editor, commandName ) {
- return ( data, cancel ) => {
- const command = editor.commands.get( commandName );
- if ( command.isEnabled ) {
- editor.execute( commandName );
- cancel();
- }
- };
- }
- // Additional filtering rule for enabling "delete" and "forwardDelete" commands if selection is on range boundaries:
- //
- // Does not allow to enable command when selection focus is:
- // - is on marker start - "delete" - to prevent removing content before marker
- // - is on marker end - "forwardDelete" - to prevent removing content after marker
- function filterDeleteCommandsOnMarkerBoundaries( selection, markerRange ) {
- return name => {
- if ( name == 'delete' && markerRange.start.isEqual( selection.focus ) ) {
- return false;
- }
- // Only for collapsed selection - non-collapsed seleciton that extends over a marker is handled elsewhere.
- if ( name == 'forwardDelete' && selection.isCollapsed && markerRange.end.isEqual( selection.focus ) ) {
- return false;
- }
- return true;
- };
- }
- // Ensures that model.deleteContent() does not delete outside exception markers ranges.
- //
- // The enforced restrictions are:
- // - only execute deleteContent() inside exception markers
- // - restrict passed selection to exception marker
- function restrictDeleteContent( editor ) {
- return ( evt, args ) => {
- const [ selection ] = args;
- const marker = getMarkerAtPosition( editor, selection.focus ) || getMarkerAtPosition( editor, selection.anchor );
- // Stop method execution if marker was not found at selection focus.
- if ( !marker ) {
- evt.stop();
- return;
- }
- // Collapsed selection inside exception marker does not require fixing.
- if ( selection.isCollapsed ) {
- return;
- }
- // Shrink the selection to the range inside exception marker.
- const allowedToDelete = marker.getRange().getIntersection( selection.getFirstRange() );
- // Some features uses selection passed to model.deleteContent() to set the selection afterwards. For this we need to properly modify
- // either the document selection using change block...
- if ( selection.is( 'documentSelection' ) ) {
- editor.model.change( writer => {
- writer.setSelection( allowedToDelete );
- } );
- }
- // ... or by modifying passed selection instance directly.
- else {
- selection.setTo( allowedToDelete );
- }
- };
- }
- // Ensures that input command is executed with a range that is inside exception marker.
- //
- // This restriction is due to fact that using native spell check changes text outside exception marker.
- function disallowInputExecForWrongRange( editor ) {
- return ( evt, args ) => {
- const [ options ] = args;
- const { range } = options;
- // Only check "input" command executed with a range value.
- // Selection might be set in exception marker but passed range might point elsewhere.
- if ( !range ) {
- return;
- }
- if ( !isRangeInsideSingleMarker( editor, range ) ) {
- evt.stop();
- }
- };
- }
- function isRangeInsideSingleMarker( editor, range ) {
- const markerAtStart = getMarkerAtPosition( editor, range.start );
- const markerAtEnd = getMarkerAtPosition( editor, range.end );
- return markerAtStart && markerAtEnd && markerAtEnd === markerAtStart;
- }
- // Checks if new marker range is flat. Non-flat ranges might appear during upcast conversion in nested structures, ie tables.
- //
- // Note: This marker fixer only consider case which is possible to create using StandardEditing mode plugin.
- // Markers created by developer in the data might break in many other ways.
- //
- // See #6003.
- function ensureNewMarkerIsFlat( editor ) {
- const model = editor.model;
- return ( evt, marker, oldRange, newRange ) => {
- if ( !oldRange && !newRange.isFlat ) {
- model.change( writer => {
- const start = newRange.start;
- const end = newRange.end;
- const startIsHigherInTree = start.path.length > end.path.length;
- const fixedStart = startIsHigherInTree ? newRange.start : writer.createPositionAt( end.parent, 0 );
- const fixedEnd = startIsHigherInTree ? writer.createPositionAt( start.parent, 'end' ) : newRange.end;
- writer.updateMarker( marker, {
- range: writer.createRange( fixedStart, fixedEnd )
- } );
- } );
- }
- };
- }
- function onlyAllowAttributesFromList( allowedAttributes ) {
- return ( context, attributeName ) => {
- if ( context.startsWith( '$clipboardHolder' ) ) {
- return allowedAttributes.includes( attributeName );
- }
- };
- }
- function allowTextOnlyInClipboardHolder( context, childDefinition ) {
- if ( context.startsWith( '$clipboardHolder' ) ) {
- return childDefinition.name === '$text';
- }
- }
|