UNPKG

@northernco/ckeditor5-anchor-drupal

Version:

Drupal CKEditor 5 integration

683 lines (581 loc) 22.3 kB
/** * @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 anchor/anchorediting */ import { Plugin } from 'ckeditor5/src/core'; import { MouseObserver } from 'ckeditor5/src/engine'; import { TwoStepCaretMovement } from 'ckeditor5/src/typing'; import { inlineHighlight } from 'ckeditor5/src/typing'; import { Input } from 'ckeditor5/src/typing'; import { Clipboard } from 'ckeditor5/src/clipboard'; import AnchorCommand from './anchorcommand'; import UnanchorCommand from './unanchorcommand'; import ManualDecorator from './utils/manualdecorator'; import findAttributeRange from '@ckeditor/ckeditor5-typing/src/utils/findattributerange'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; import { viewToModelPositionOutsideModelElement } from "ckeditor5/src/widget"; import { createAnchorElement, createEmptyAnchorElement, createEmptyPlaceholderAnchorElement, ensureSafeUrl, getLocalizedDecorators, normalizeDecorators } from './utils'; import '../theme/anchor.css'; const HIGHLIGHT_CLASS = 'ck-anchor_selected'; const DECORATOR_AUTOMATIC = 'automatic'; const DECORATOR_MANUAL = 'manual'; const EXTERNAL_LINKS_REGEXP = /^(https?:)?\/\//; /** * The anchor engine feature. * * It introduces the `anchorId="url"` attribute in the model which renders to the view as a `<a id="url">` element * as well as `'anchor'` and `'unanchor'` commands. * * @extends module:core/plugin~Plugin */ export default class AnchorEditing extends Plugin { /** * @inheritDoc */ static get pluginName() { return 'AnchorEditing'; } /** * @inheritDoc */ static get requires() { // Clipboard is required for handling cut and paste events while typing over the anchor. return [ TwoStepCaretMovement, Input, Clipboard ]; } /** * @inheritDoc */ constructor( editor ) { super( editor ); editor.config.define( 'anchor', { addTargetToExternalAnchors: false } ); } /** * @inheritDoc */ init() { const editor = this.editor; // Allow anchor attribute on all inline nodes. editor.model.schema.extend( '$text', { allowAttributes: 'anchorId' } ); editor.model.schema.register('anchor', { allowContentOf: '$inlineObject', allowWhere: '$inlineObject', inheritTypesFrom: '$inlineObject', allowAttributes: [ 'class', 'id', 'anchorId', 'name' ] }); editor.conversion.for( 'dataDowncast' ) .attributeToElement( { model: { name: '$text', key: 'anchorId', }, view: createAnchorElement, }); editor.conversion.for('dataDowncast').elementToElement({ model: 'anchor', view: (modelItem, viewWriter) => { return createEmptyAnchorElement( modelItem.getAttribute('anchorId'), viewWriter); } }); editor.conversion.for( 'editingDowncast' ) .attributeToElement( { model: 'anchorId', view: ( id, conversionApi ) => { if (id) { return createAnchorElement( ensureSafeUrl( id ), conversionApi ); } else { return null; } } } ); editor.conversion.for('editingDowncast').elementToElement({ model: 'anchor', view: (modelItem, viewWriter) => { return createEmptyPlaceholderAnchorElement( modelItem.getAttribute('anchorId'), viewWriter, true); } }); editor.conversion.for( 'upcast' ) .elementToAttribute( { view: { name: 'a', attributes: { id: true, } }, model: { key: 'anchorId', value: viewElement => { if (viewElement.childCount < 1) { return; } if (viewElement.hasAttribute('href')) { return; } return viewElement.getAttribute( 'id' ); } } } ); editor.conversion.for( 'upcast' ) .elementToAttribute( { view: { name: 'a', attributes: { name: true, } }, model: { key: 'anchorId', value: viewElement => { if (viewElement.childCount < 1) { return; } return viewElement.getAttribute( 'name' ); } } } ); editor.conversion.for( 'upcast' ) .elementToElement( { view: { name: 'a', attributes: { id: true, } }, model: ( viewElement, { writer } ) => { if (viewElement.childCount > 0) { return; } return writer.createElement( 'anchor', { anchorId: viewElement.getAttribute('id') } ); } } ); editor.conversion.for( 'upcast' ) .elementToElement( { view: { name: 'a', attributes: { name: true } }, model: ( viewElement, { writer } ) => { if (viewElement.childCount > 0) { return; } return writer.createElement( 'anchor', { anchorId: viewElement.getAttribute('name') } ); } } ); // Create anchoring commands. editor.commands.add( 'anchor', new AnchorCommand( editor ) ); editor.commands.add( 'unanchor', new UnanchorCommand( editor ) ); const anchorDecorators = getLocalizedDecorators( editor.t, normalizeDecorators( editor.config.get( 'anchor.decorators' ) ) ); this._enableAutomaticDecorators( anchorDecorators.filter( item => item.mode === DECORATOR_AUTOMATIC ) ); this._enableManualDecorators( anchorDecorators.filter( item => item.mode === DECORATOR_MANUAL ) ); // Enable two-step caret movement for `anchorId` attribute. const twoStepCaretMovementPlugin = editor.plugins.get( TwoStepCaretMovement ); twoStepCaretMovementPlugin.registerAttribute( 'anchorId' ); // Setup highlight over selected anchor. inlineHighlight( editor, 'anchorId', 'a', HIGHLIGHT_CLASS ); // Change the attributes of the selection in certain situations after the anchor was inserted into the document. this._enableInsertContentSelectionAttributesFixer(); // Handle a click at the beginning/end of a anchor element. this._enableClickingAfterAnchor(); // Handle typing over the anchor. this._enableTypingOverAnchor(); // Handle removing the content after the anchor element. this._handleDeleteContentAfterAnchor(); editor.editing.mapper.on( 'viewToModelPosition', viewToModelPositionOutsideModelElement( editor.model, viewElement => viewElement.hasClass( 'ck-anchor-placeholder' ) ) ); } /** * Processes an array of configured {@link module:anchor/anchor~AnchorDecoratorAutomaticDefinition automatic decorators} * and registers a {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher downcast dispatcher} * for each one of them. Downcast dispatchers are obtained using the * {@link module:anchor/utils~AutomaticDecorators#getDispatcher} method. * * **Note**: This method also activates the automatic external anchor decorator if enabled with * {@link module:anchor/anchor~AnchorConfig#addTargetToExternalAnchors `config.anchor.addTargetToExternalAnchors`}. * * @private * @param {Array.<module:anchor/anchor~AnchorDecoratorAutomaticDefinition>} automaticDecoratorDefinitions */ _enableAutomaticDecorators( automaticDecoratorDefinitions ) { const editor = this.editor; // Store automatic decorators in the command instance as we do the same with manual decorators. // Thanks to that, `AnchorImageEditing` plugin can re-use the same definitions. const command = editor.commands.get( 'anchor' ); const automaticDecorators = command.automaticDecorators; // Adds a default decorator for external anchors. if ( editor.config.get( 'anchor.addTargetToExternalAnchors' ) ) { automaticDecorators.add( { id: 'anchorIsExternal', mode: DECORATOR_AUTOMATIC, callback: url => EXTERNAL_LINKS_REGEXP.test( url ), attributes: { target: '_blank', rel: 'noopener noreferrer' } } ); } automaticDecorators.add( automaticDecoratorDefinitions ); if ( automaticDecorators.length ) { editor.conversion.for( 'downcast' ).add( automaticDecorators.getDispatcher() ); } } /** * Processes an array of configured {@link module:anchor/anchor~AnchorDecoratorManualDefinition manual decorators}, * transforms them into {@link module:anchor/utils~ManualDecorator} instances and stores them in the * {@link module:anchor/anchorcommand~AnchorCommand#manualDecorators} collection (a model for manual decorators state). * * Also registers an {@link module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToElement attribute-to-element} * converter for each manual decorator and extends the {@link module:engine/model/schema~Schema model's schema} * with adequate model attributes. * * @private * @param {Array.<module:anchor/anchor~AnchorDecoratorManualDefinition>} manualDecoratorDefinitions */ _enableManualDecorators( manualDecoratorDefinitions ) { if ( !manualDecoratorDefinitions.length ) { return; } const editor = this.editor; const command = editor.commands.get( 'anchor' ); const manualDecorators = command.manualDecorators; manualDecoratorDefinitions.forEach( decorator => { editor.model.schema.extend( '$text', { allowAttributes: decorator.id } ); // Keeps reference to manual decorator to decode its name to attributes during downcast. manualDecorators.add( new ManualDecorator( decorator ) ); editor.conversion.for( 'downcast' ).attributeToElement( { model: decorator.id, view: ( manualDecoratorName, { writer } ) => { if ( manualDecoratorName ) { const attributes = manualDecorators.get( decorator.id ).attributes; const element = writer.createAttributeElement( 'a', attributes, { priority: 5 } ); writer.setCustomProperty( 'anchor', true, element ); return element; } } } ); editor.conversion.for( 'upcast' ).elementToAttribute( { view: { name: 'a', attributes: manualDecorators.get( decorator.id ).attributes }, model: { key: decorator.id } } ); } ); } /** * Starts listening to {@link module:engine/model/model~Model#event:insertContent} and corrects the model * selection attributes if the selection is at the end of a anchor after inserting the content. * * The purpose of this action is to improve the overall UX because the user is no longer "trapped" by the * `anchorId` attribute of the selection and they can type a "clean" (`anchorId`–less) text right away. * * See https://github.com/ckeditor/ckeditor5/issues/6053. * * @private */ _enableInsertContentSelectionAttributesFixer() { const editor = this.editor; const model = editor.model; const selection = model.document.selection; const anchorCommand = editor.commands.get( 'anchor' ); this.listenTo( model, 'insertContent', () => { const nodeBefore = selection.anchor.nodeBefore; const nodeAfter = selection.anchor.nodeAfter; // NOTE: ↰ and ↱ represent the gravity of the selection. // The only truly valid case is: // // ↰ // ...<$text anchorId="foo">INSERTED[]</$text> // // If the selection is not "trapped" by the `anchorId` attribute after inserting, there's nothing // to fix there. if ( !selection.hasAttribute( 'anchorId' ) ) { return; } // Filter out the following case where a anchor with the same id (e.g. <a id="foo">INSERTED</a>) is inserted // in the middle of an existing anchor: // // Before insertion: // ↰ // <$text anchorId="foo">l[]ink</$text> // // Expected after insertion: // ↰ // <$text anchorId="foo">lINSERTED[]ink</$text> // if ( !nodeBefore ) { return; } // Filter out the following case where the selection has the "anchorId" attribute because the // gravity is overridden and some text with another attribute (e.g. <b>INSERTED</b>) is inserted: // // Before insertion: // // ↱ // <$text anchorId="foo">[]anchor</$text> // // Expected after insertion: // // ↱ // <$text bold="true">INSERTED</$text><$text anchorId="foo">[]anchor</$text> // if ( !nodeBefore.hasAttribute( 'anchorId' ) ) { return; } // Filter out the following case where a anchor is a inserted in the middle (or before) another anchor // (different URLs, so they will not merge). In this (let's say weird) case, we can leave the selection // attributes as they are because the user will end up writing in one anchor or another anyway. // // Before insertion: // // ↰ // <$text anchorId="foo">l[]ink</$text> // // Expected after insertion: // // ↰ // <$text anchorId="foo">l</$text><$text anchorId="bar">INSERTED[]</$text><$text anchorId="foo">ink</$text> // if ( nodeAfter && nodeAfter.hasAttribute( 'anchorId' ) ) { return; } model.change( writer => { removeAnchorAttributesFromSelection( writer, anchorCommand.manualDecorators ); } ); }, { priority: 'low' } ); } /** * Starts listening to {@link module:engine/view/document~Document#event:mousedown} and * {@link module:engine/view/document~Document#event:selectionChange} and puts the selection before/after a anchor node * if clicked at the beginning/ending of the anchor. * * The purpose of this action is to allow typing around the anchor node directly after a click. * * See https://github.com/ckeditor/ckeditor5/issues/1016. * * @private */ _enableClickingAfterAnchor() { const editor = this.editor; const anchorCommand = editor.commands.get( 'anchor' ); editor.editing.view.addObserver( MouseObserver ); let clicked = false; // Detect the click. this.listenTo( editor.editing.view.document, 'mousedown', () => { clicked = true; } ); // When the selection has changed... this.listenTo( editor.editing.view.document, 'selectionChange', () => { if ( !clicked ) { return; } // ...and it was caused by the click... clicked = false; const selection = editor.model.document.selection; // ...and no text is selected... if ( !selection.isCollapsed ) { return; } // ...and clicked text is the anchor... if ( !selection.hasAttribute( 'anchorId' ) ) { return; } const position = selection.getFirstPosition(); const anchorRange = findAttributeRange( position, 'anchorId', selection.getAttribute( 'anchorId' ), editor.model ); // ...check whether clicked start/end boundary of the anchor. // If so, remove the `anchorId` attribute. if ( position.isTouching( anchorRange.start ) || position.isTouching( anchorRange.end ) ) { editor.model.change( writer => { removeAnchorAttributesFromSelection( writer, anchorCommand.manualDecorators ); } ); } } ); } /** * Starts listening to {@link module:engine/model/model~Model#deleteContent} and {@link module:engine/model/model~Model#insertContent} * and checks whether typing over the anchor. If so, attributes of removed text are preserved and applied to the inserted text. * * The purpose of this action is to allow modifying a text without loosing the `anchorId` attribute (and other). * * See https://github.com/ckeditor/ckeditor5/issues/4762. * * @private */ _enableTypingOverAnchor() { const editor = this.editor; const view = editor.editing.view; // Selection attributes when started typing over the anchor. let selectionAttributes; // Whether pressed `Backspace` or `Delete`. If so, attributes should not be preserved. let deletedContent; // Detect pressing `Backspace` / `Delete`. this.listenTo( view.document, 'delete', () => { deletedContent = true; }, { priority: 'high' } ); // Listening to `model#deleteContent` allows detecting whether selected content was a anchor. // If so, before removing the element, we will copy its attributes. this.listenTo( editor.model, 'deleteContent', () => { const selection = editor.model.document.selection; // Copy attributes only if anything is selected. if ( selection.isCollapsed ) { return; } // When the content was deleted, do not preserve attributes. if ( deletedContent ) { deletedContent = false; return; } // Enabled only when typing. if ( !isTyping( editor ) ) { return; } if ( shouldCopyAttributes( editor.model ) ) { selectionAttributes = selection.getAttributes(); } }, { priority: 'high' } ); // Listening to `model#insertContent` allows detecting the content insertion. // We want to apply attributes that were removed while typing over the anchor. this.listenTo( editor.model, 'insertContent', ( evt, [ element ] ) => { deletedContent = false; // Enabled only when typing. if ( !isTyping( editor ) ) { return; } if ( !selectionAttributes ) { return; } editor.model.change( writer => { for ( const [ attribute, value ] of selectionAttributes ) { writer.setAttribute( attribute, value, element ); } } ); selectionAttributes = null; }, { priority: 'high' } ); } /** * Starts listening to {@link module:engine/model/model~Model#deleteContent} and checks whether * removing a content right after the "anchorId" attribute. * * If so, the selection should not preserve the `anchorId` attribute. However, if * the {@link module:typing/twostepcaretmovement~TwoStepCaretMovement} plugin is active and * the selection has the "anchorId" attribute due to overriden gravity (at the end), the `anchorId` attribute should stay untouched. * * The purpose of this action is to allow removing the anchor text and keep the selection outside the anchor. * * See https://github.com/ckeditor/ckeditor5/issues/7521. * * @private */ _handleDeleteContentAfterAnchor() { const editor = this.editor; const model = editor.model; const selection = model.document.selection; const view = editor.editing.view; const anchorCommand = editor.commands.get( 'anchor' ); // A flag whether attributes `anchorId` attribute should be preserved. let shouldPreserveAttributes = false; // A flag whether the `Backspace` key was pressed. let hasBackspacePressed = false; // Detect pressing `Backspace`. this.listenTo( view.document, 'delete', ( evt, data ) => { hasBackspacePressed = data.domEvent.keyCode === keyCodes.backspace; }, { priority: 'high' } ); // Before removing the content, check whether the selection is inside a anchor or at the end of anchor but with 2-SCM enabled. // If so, we want to preserve anchor attributes. this.listenTo( model, 'deleteContent', () => { // Reset the state. shouldPreserveAttributes = false; const position = selection.getFirstPosition(); const anchorId = selection.getAttribute( 'anchorId' ); if ( !anchorId ) { return; } const anchorRange = findAttributeRange( position, 'anchorId', anchorId, model ); // Preserve `anchorId` attribute if the selection is in the middle of the anchor or // the selection is at the end of the anchor and 2-SCM is activated. shouldPreserveAttributes = anchorRange.containsPosition( position ) || anchorRange.end.isEqual( position ); }, { priority: 'high' } ); // After removing the content, check whether the current selection should preserve the `anchorId` attribute. this.listenTo( model, 'deleteContent', () => { // If didn't press `Backspace`. if ( !hasBackspacePressed ) { return; } hasBackspacePressed = false; // Disable the mechanism if inside a anchor (`<$text url="foo">F[]oo</$text>` or <$text url="foo">Foo[]</$text>`). if ( shouldPreserveAttributes ) { return; } // Use `model.enqueueChange()` in order to execute the callback at the end of the changes process. editor.model.enqueueChange( writer => { removeAnchorAttributesFromSelection( writer, anchorCommand.manualDecorators ); } ); }, { priority: 'low' } ); } } // Make the selection free of anchor-related model attributes. // All anchor-related model attributes start with "anchor". That includes not only "anchorId" // but also all decorator attributes (they have dynamic names). // // @param {module:engine/model/writer~Writer} writer // @param {module:utils/collection~Collection} manualDecorators function removeAnchorAttributesFromSelection( writer, manualDecorators ) { writer.removeSelectionAttribute( 'anchorId' ); for ( const decorator of manualDecorators ) { writer.removeSelectionAttribute( decorator.id ); } } // Checks whether selection's attributes should be copied to the new inserted text. // // @param {module:engine/model/model~Model} model // @returns {Boolean} function shouldCopyAttributes( model ) { const selection = model.document.selection; const firstPosition = selection.getFirstPosition(); const lastPosition = selection.getLastPosition(); const nodeAtFirstPosition = firstPosition.nodeAfter; // The text anchor node does not exist... if ( !nodeAtFirstPosition ) { return false; } // ...or it isn't the text node... if ( !nodeAtFirstPosition.is( '$text' ) ) { return false; } // ...or isn't the anchor. if ( !nodeAtFirstPosition.hasAttribute( 'anchorId' ) ) { return false; } // `textNode` = the position is inside the anchor element. // `nodeBefore` = the position is at the end of the anchor element. const nodeAtLastPosition = lastPosition.textNode || lastPosition.nodeBefore; // If both references the same node selection contains a single text node. if ( nodeAtFirstPosition === nodeAtLastPosition ) { return true; } // If nodes are not equal, maybe the anchor nodes has defined additional attributes inside. // First, we need to find the entire anchor range. const anchorRange = findAttributeRange( firstPosition, 'anchorId', nodeAtFirstPosition.getAttribute( 'anchorId' ), model ); // Then we can check whether selected range is inside the found anchor range. If so, attributes should be preserved. return anchorRange.containsRange( model.createRange( firstPosition, lastPosition ), true ); } // Checks whether provided changes were caused by typing. // // @params {module:core/editor/editor~Editor} editor // @returns {Boolean} function isTyping( editor ) { const currentBatch = editor.model.change( writer => writer.batch ); return currentBatch.isTyping; }