Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix: Nested editable. #17809

Merged
merged 16 commits into from
Feb 19, 2025
Merged
2 changes: 1 addition & 1 deletion docs/tutorials/crash-course/keystrokes.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Currently, our `highlight` plugin requires the user to click the button in the e

## Adding keyboard shortcuts

A common shortcut for highlighting text is <kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>H</kbd> (on Windows systems), so this is what we are going to use in our plugin. On macOS these keystrokes will get automatically translated to <kbd>Cmd</kbd> + <kbd>Alt</kbd> + <kbd>H</kbd>
A common shortcut for highlighting text is <kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>H</kbd> (on Windows systems), so this is what we are going to use in our plugin. On macOS these keystrokes will get automatically translated to <kbd>Cmd</kbd> + <kbd></kbd> + <kbd>H</kbd>

To execute the `highlight` command when those keys are pressed, add the following code to the end of the `Highlight` function:

Expand Down
1 change: 1 addition & 0 deletions packages/ckeditor5-typing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@ckeditor/ckeditor5-paragraph": "44.2.0",
"@ckeditor/ckeditor5-table": "44.2.0",
"@ckeditor/ckeditor5-undo": "44.2.0",
"@ckeditor/ckeditor5-widget": "44.2.0",
"typescript": "5.0.4",
"webpack": "^5.94.0",
"webpack-cli": "^5.1.4"
Expand Down
20 changes: 20 additions & 0 deletions packages/ckeditor5-typing/src/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
* @module typing/delete
*/

import type { ViewDocumentKeyDownEvent } from '@ckeditor/ckeditor5-engine';
import { Plugin } from '@ckeditor/ckeditor5-core';
import { keyCodes } from '@ckeditor/ckeditor5-utils';
import DeleteCommand from './deletecommand.js';
import DeleteObserver, { type ViewDocumentDeleteEvent } from './deleteobserver.js';

Expand Down Expand Up @@ -82,6 +84,24 @@ export default class Delete extends Plugin {
view.scrollToTheSelection();
}, { priority: 'low' } );

// Handle the Backspace key while at the beginning of a nested editable. See https://github.com/ckeditor/ckeditor5/issues/17383.
this.listenTo<ViewDocumentKeyDownEvent>( viewDocument, 'keydown', ( evt, data ) => {
if (
viewDocument.isComposing ||
data.keyCode != keyCodes.backspace ||
!modelDocument.selection.isCollapsed
) {
return;
}

const ancestorLimit = editor.model.schema.getLimitElement( modelDocument.selection );
const limitStartPosition = editor.model.createPositionAt( ancestorLimit, 0 );

if ( limitStartPosition.isTouching( modelDocument.selection.getFirstPosition()! ) ) {
data.preventDefault();
}
} );

if ( this.editor.plugins.has( 'UndoEditing' ) ) {
this.listenTo<ViewDocumentDeleteEvent>( viewDocument, 'delete', ( evt, data ) => {
if ( this._undoOnBackspace && data.direction == 'backward' && data.sequence == 1 && data.unit == 'codePoint' ) {
Expand Down
105 changes: 102 additions & 3 deletions packages/ckeditor5-typing/tests/delete.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,64 @@
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/

import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js';
import Delete from '../src/delete.js';
import Typing from '../src/typing.js';
import Widget from '@ckeditor/ckeditor5-widget/src/widget.js';
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph.js';
import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting.js';
import { toWidget, toWidgetEditable } from '@ckeditor/ckeditor5-widget';
import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata.js';
import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js';
import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model.js';
import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo.js';
import Batch from '@ckeditor/ckeditor5-engine/src/model/batch.js';
import env from '@ckeditor/ckeditor5-utils/src/env.js';
import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js';
import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard.js';
import { fireBeforeInputDomEvent } from './_utils/utils.js';

/* globals document */

describe( 'Delete feature', () => {
let element, editor, viewDocument;
let element, editor, model, viewDocument;

beforeEach( () => {
element = document.createElement( 'div' );
document.body.appendChild( element );

return ClassicTestEditor
.create( element, { plugins: [ Delete ] } )
.create( element, { plugins: [ Paragraph, Widget, Delete, Typing ] } )
.then( newEditor => {
editor = newEditor;
model = editor.model;
viewDocument = editor.editing.view.document;

model.schema.register( 'widget', {
inheritAllFrom: '$blockObject'
} );
model.schema.register( 'nested', {
allowIn: 'widget',
isLimit: true
} );
model.schema.extend( '$text', {
allowIn: [ 'nested' ],
allowAttributes: [ 'attr', 'bttr' ]
} );

editor.conversion.for( 'downcast' )
.elementToElement( {
model: 'widget',
view: ( modelItem, { writer } ) => {
const div = writer.createContainerElement( 'div' );

return toWidget( div, writer, { label: 'element label' } );
}
} )
.elementToElement( {
model: 'nested',
view: ( modelItem, { writer } ) =>
toWidgetEditable( writer.createEditableElement( 'figcaption', { contenteditable: true } ), writer )
} );
} );
} );

Expand Down Expand Up @@ -76,6 +110,38 @@ describe( 'Delete feature', () => {
expect( spy.calledWithMatch( 'delete', { unit: 'character', sequence: 5 } ) ).to.be.true;
} );

// See https://github.com/ckeditor/ckeditor5/issues/17383.
it( 'handles the backspace key in a nested editable', () => {
setModelData( model, '<widget><nested>fo[]</nested></widget>' );

expect( clickBackspace( editor ).preventedKeyDown ).to.be.false;

expect( getModelData( model ) ).to.equal( '<widget><nested>f[]</nested></widget>' );

expect( clickBackspace( editor ).preventedKeyDown ).to.be.false;

expect( getModelData( model ) ).to.equal( '<widget><nested>[]</nested></widget>' );
} );

// See https://github.com/ckeditor/ckeditor5/issues/17383.
it( 'handles the backspace key in an empty nested editable', () => {
setModelData( model, '<widget><nested>[]</nested></widget>' );

expect( clickBackspace( editor ).preventedKeyDown ).to.be.true;

expect( getModelData( model ) ).to.equal( '<widget><nested>[]</nested></widget>' );
} );

// See https://github.com/ckeditor/ckeditor5/issues/17383.
// There was an edge case tied to deleting whole lines.
it( 'handles the backspace key + meta key in a nested editable', () => {
setModelData( model, '<widget><nested>[]</nested></widget>' );

expect( clickBackspace( editor, true ).preventedKeyDown ).to.be.true;

expect( getModelData( model ) ).to.equal( '<widget><nested>[]</nested></widget>' );
} );

it( 'passes options.selection parameter to delete command if selection to remove was specified and unit is "selection"', () => {
editor.setData( '<p>Foobar</p>' );

Expand Down Expand Up @@ -494,6 +560,39 @@ describe( 'Delete feature - undo by pressing backspace', () => {
} );
} );

function clickBackspace( editor, metaKey = false ) {
const view = editor.editing.view;
const viewDocument = view.document;

const keyEventData = {
keyCode: getCode( 'Backspace' ),
preventDefault: sinon.spy(),
domTarget: view.getDomRoot(),
metaKey
};

const viewRange = viewDocument.selection.getFirstRange();
const viewRoot = view.domConverter.viewToDom( view.document.getRoot() );
const domRange = view.domConverter.viewRangeToDom( viewRange );

// First fire keydown event.
viewDocument.fire( 'keydown', new DomEventData( viewDocument, keyEventData, keyEventData ) );

// Then fire beforeinput if it's not suppressed.
const preventedKeyDown = keyEventData.preventDefault.called;

if ( !preventedKeyDown ) {
fireBeforeInputDomEvent( viewRoot, {
inputType: 'deleteContentBackward',
ranges: [ domRange ]
} );
}

return {
preventedKeyDown
};
}

function getDomEvent() {
return {
preventDefault: sinon.spy()
Expand Down