Skip to content

Commit

Permalink
Merge pull request #7590 from ckeditor/i/4762
Browse files Browse the repository at this point in the history
Feature (link): Typing over the selected link will not remove the link itself. Instead, the typed text will replace the link text. Closes #4762.
  • Loading branch information
jodator authored Jul 15, 2020
2 parents e90a4e6 + 118262a commit de476bb
Show file tree
Hide file tree
Showing 7 changed files with 564 additions and 22 deletions.
2 changes: 1 addition & 1 deletion packages/ckeditor5-link/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"ckeditor5-plugin"
],
"dependencies": {
"@ckeditor/ckeditor5-clipboard": "^20.0.0",
"@ckeditor/ckeditor5-core": "^20.0.0",
"@ckeditor/ckeditor5-engine": "^20.0.0",
"@ckeditor/ckeditor5-image": "^20.0.0",
Expand All @@ -21,7 +22,6 @@
"devDependencies": {
"@ckeditor/ckeditor5-basic-styles": "^20.0.0",
"@ckeditor/ckeditor5-block-quote": "^20.0.0",
"@ckeditor/ckeditor5-clipboard": "^20.0.0",
"@ckeditor/ckeditor5-code-block": "^20.0.0",
"@ckeditor/ckeditor5-editor-classic": "^20.0.0",
"@ckeditor/ckeditor5-enter": "^20.0.0",
Expand Down
136 changes: 135 additions & 1 deletion packages/ckeditor5-link/src/linkediting.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver';
import TwoStepCaretMovement from '@ckeditor/ckeditor5-typing/src/twostepcaretmovement';
import Input from '@ckeditor/ckeditor5-typing/src/input';
import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
import LinkCommand from './linkcommand';
import UnlinkCommand from './unlinkcommand';
import AutomaticDecorators from './utils/automaticdecorators';
Expand Down Expand Up @@ -44,7 +46,8 @@ export default class LinkEditing extends Plugin {
* @inheritDoc
*/
static get requires() {
return [ TwoStepCaretMovement ];
// Clipboard is required for handling cut and paste events while typing over the link.
return [ TwoStepCaretMovement, Input, Clipboard ];
}

/**
Expand Down Expand Up @@ -110,6 +113,9 @@ export default class LinkEditing extends Plugin {

// Handle a click at the beginning/end of a link element.
this._enableClickingAfterLink();

// Handle typing over the link.
this._enableTypingOverLink();
}

/**
Expand Down Expand Up @@ -414,4 +420,132 @@ export default class LinkEditing extends Plugin {
}
} );
}

/**
* Starts listening to {@link module:engine/model/model~Model#deleteContent} and {@link module:engine/model/model~Model#insertContent}
* and checks whether typing over the link. 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 `linkHref` attribute (and other).
*
* See https://github.com/ckeditor/ckeditor5/issues/4762.
*
* @private
*/
_enableTypingOverLink() {
const editor = this.editor;
const view = editor.editing.view;

// Selection attributes when started typing over the link.
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 link.
// 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 link.
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' } );
}
}

// 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 link 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 link.
if ( !nodeAtFirstPosition.hasAttribute( 'linkHref' ) ) {
return false;
}

// `textNode` = the position is inside the link element.
// `nodeBefore` = the position is at the end of the link 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 link nodes has defined additional attributes inside.
// First, we need to find the entire link range.
const linkRange = findLinkRange( firstPosition, nodeAtFirstPosition.getAttribute( 'linkHref' ), model );

// Then we can check whether selected range is inside the found link range. If so, attributes should be preserved.
return linkRange.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 input = editor.plugins.get( 'Input' );

return input.isInput( editor.model.change( writer => writer.batch ) );
}
Loading

0 comments on commit de476bb

Please sign in to comment.