/** * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md. */ /** * @module image/widget/widget */ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import WidgetEngine from './widgetengine'; import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver'; import ModelRange from '@ckeditor/ckeditor5-engine/src/model/range'; import ModelSelection from '@ckeditor/ckeditor5-engine/src/model/selection'; import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element'; import ViewEditableElement from '@ckeditor/ckeditor5-engine/src/view/editableelement'; import RootEditableElement from '@ckeditor/ckeditor5-engine/src/view/rooteditableelement'; import { isWidget } from './utils'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; /** * The widget plugin. * Adds default {@link module:engine/view/document~Document#event:mousedown mousedown} handling on widget elements. * * @extends module:core/plugin~Plugin. */ export default class Widget extends Plugin { /** * @inheritDoc */ static get requires() { return [ WidgetEngine ]; } /** * @inheritDoc */ init() { const viewDocument = this.editor.editing.view; // If mouse down is pressed on widget - create selection over whole widget. viewDocument.addObserver( MouseObserver ); this.listenTo( viewDocument, 'mousedown', ( ...args ) => this._onMousedown( ...args ) ); // Handle custom keydown behaviour. this.listenTo( viewDocument, 'keydown', ( ...args ) => this._onKeydown( ...args ), { priority: 'high' } ); } /** * Handles {@link module:engine/view/document~Document#event:mousedown mousedown} events on widget elements. * * @private * @param {module:utils/eventinfo~EventInfo} eventInfo * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData */ _onMousedown( eventInfo, domEventData ) { const editor = this.editor; const viewDocument = editor.editing.view; let element = domEventData.target; // Do nothing if inside nested editable. if ( isInsideNestedEditable( element ) ) { return; } // If target is not a widget element - check if one of the ancestors is. if ( !isWidget( element ) ) { element = element.findAncestor( isWidget ); if ( !element ) { return; } } domEventData.preventDefault(); // Focus editor if is not focused already. if ( !viewDocument.isFocused ) { viewDocument.focus(); } // Create model selection over widget. const modelElement = editor.editing.mapper.toModelElement( element ); editor.document.enqueueChanges( ( ) => { this._setSelectionOverElement( modelElement ); } ); } /** * Handles {@link module:engine/view/document~Document#event:keydown keydown} events. * * @private * @param {module:utils/eventinfo~EventInfo} eventInfo * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData */ _onKeydown( eventInfo, domEventData ) { const keyCode = domEventData.keyCode; const isForward = keyCode == keyCodes.delete || keyCode == keyCodes.arrowdown || keyCode == keyCodes.arrowright; // Checks if delete/backspace or arrow keys were handled and then prevents default event behaviour and stops // event propagation. if ( ( isDeleteKeyCode( keyCode ) && this._handleDelete( isForward ) ) || ( isArrowKeyCode( keyCode ) && this._handleArrowKeys( isForward ) ) ) { domEventData.preventDefault(); eventInfo.stop(); } } /** * Handles delete keys: backspace and delete. * * @private * @param {Boolean} isForward Set to true if delete was performed in forward direction. * @returns {Boolean|undefined} Returns `true` if keys were handled correctly. */ _handleDelete( isForward ) { const modelDocument = this.editor.document; const modelSelection = modelDocument.selection; // Do nothing on non-collapsed selection. if ( !modelSelection.isCollapsed ) { return; } const objectElement = this._getObjectElementNextToSelection( isForward ); if ( objectElement ) { modelDocument.enqueueChanges( () => { // Remove previous element if empty. const previousNode = modelSelection.anchor.parent; if ( previousNode.isEmpty ) { const batch = modelDocument.batch(); batch.remove( previousNode ); } this._setSelectionOverElement( objectElement ); } ); return true; } } /** * Handles arrow keys. * * @param {Boolean} isForward Set to true if arrow key should be handled in forward direction. * @returns {Boolean|undefined} Returns `true` if keys were handled correctly. */ _handleArrowKeys( isForward ) { const modelDocument = this.editor.document; const schema = modelDocument.schema; const modelSelection = modelDocument.selection; const objectElement = modelSelection.getSelectedElement(); // if object element is selected. if ( objectElement && schema.objects.has( objectElement.name ) ) { const position = isForward ? modelSelection.getLastPosition() : modelSelection.getFirstPosition(); const newRange = modelDocument.getNearestSelectionRange( position, isForward ? 'forward' : 'backward' ); if ( newRange ) { modelDocument.enqueueChanges( () => { modelSelection.setRanges( [ newRange ] ); } ); } return true; } // If selection is next to object element. // Return if not collapsed. if ( !modelSelection.isCollapsed ) { return; } const objectElement2 = this._getObjectElementNextToSelection( isForward ); if ( objectElement2 instanceof ModelElement && modelDocument.schema.objects.has( objectElement2.name ) ) { modelDocument.enqueueChanges( () => { this._setSelectionOverElement( objectElement2 ); } ); return true; } } /** * Sets {@link module:engine/model/selection~Selection document's selection} over given element. * * @private * @param {module:engine/model/element~Element} element */ _setSelectionOverElement( element ) { this.editor.document.selection.setRanges( [ ModelRange.createOn( element ) ] ); } /** * Checks if {@link module:engine/model/element~Element element} placed next to the current * {@link module:engine/model/selection~Selection model selection} exists and is marked in * {@link module:engine/model/schema~Schema schema} as `object`. * * @private * @param {Boolean} forward Direction of checking. * @returns {module:engine/model/element~Element|null} */ _getObjectElementNextToSelection( forward ) { const modelDocument = this.editor.document; const schema = modelDocument.schema; const modelSelection = modelDocument.selection; const dataController = this.editor.data; // Clone current selection to use it as a probe. We must leave default selection as it is so it can return // to its current state after undo. const probe = ModelSelection.createFromSelection( modelSelection ); dataController.modifySelection( probe, { direction: forward ? 'forward' : 'backward' } ); const objectElement = forward ? probe.focus.nodeBefore : probe.focus.nodeAfter; if ( objectElement instanceof ModelElement && schema.objects.has( objectElement.name ) ) { return objectElement; } return null; } } // Returns 'true' if provided key code represents one of the arrow keys. // // @param {Number} keyCode // @returns {Boolean} function isArrowKeyCode( keyCode ) { return keyCode == keyCodes.arrowright || keyCode == keyCodes.arrowleft || keyCode == keyCodes.arrowup || keyCode == keyCodes.arrowdown; } //Returns 'true' if provided key code represents one of the delete keys: delete or backspace. // //@param {Number} keyCode //@returns {Boolean} function isDeleteKeyCode( keyCode ) { return keyCode == keyCodes.delete || keyCode == keyCodes.backspace; } // Returns `true` when element is a nested editable or is placed inside one. // // @param {module:engine/view/element~Element} // @returns {Boolean} function isInsideNestedEditable( element ) { while ( element ) { if ( element instanceof ViewEditableElement && !( element instanceof RootEditableElement ) ) { return true; } element = element.parent; } return false; }