diff --git a/packages/ckeditor5-typing/src/twostepcaretmovement.ts b/packages/ckeditor5-typing/src/twostepcaretmovement.ts
index 4213198004e..dd1c342fb65 100644
--- a/packages/ckeditor5-typing/src/twostepcaretmovement.ts
+++ b/packages/ckeditor5-typing/src/twostepcaretmovement.ts
@@ -9,8 +9,7 @@
import { Plugin, type Editor } from '@ckeditor/ckeditor5-core';
-import { keyCodes } from '@ckeditor/ckeditor5-utils';
-
+import { keyCodes, env } from '@ckeditor/ckeditor5-utils';
import {
MouseObserver,
TouchObserver,
@@ -24,7 +23,10 @@ import {
type ViewDocumentSelectionChangeEvent,
type ViewDocumentTouchStartEvent,
type ModelInsertContentEvent,
- type ModelDeleteContentEvent
+ type ModelDeleteContentEvent,
+ type ViewElement,
+ type ViewNode,
+ type ViewDocumentFragment
} from '@ckeditor/ckeditor5-engine';
import type { ViewDocumentDeleteEvent } from './deleteobserver.js';
@@ -310,7 +312,7 @@ export default class TwoStepCaretMovement extends Plugin {
//
// or left the attribute
//
- // foo<$text attribute>bar$text>{}baz
+ // foo<$text attribute>bar$text>{}baz$text>
//
// and the gravity will be restored automatically.
if ( this._isGravityOverridden ) {
@@ -505,9 +507,25 @@ export default class TwoStepCaretMovement extends Plugin {
let clicked = false;
// This event should be fired before selection on mobile devices.
- this.listenTo( document, 'touchstart', () => {
- clicked = false;
- touched = true;
+ this.listenTo( document, 'touchstart', ( _, data ) => {
+ let middleClickHandled = false;
+
+ // On iOS devices, touching the middle of an element causes the selection to move outside of it,
+ // even if the selection was not initially within the element. This behavior interferes with
+ // features like balloons that rely on the selection being inside the element. To address this,
+ // if a middle touch on an element is detected, the selection is forcibly set to encompass the
+ // entire element. This issue is commonly observed with links.
+ // See: https://github.com/ckeditor/ckeditor5/issues/18023
+ if ( env.isiOS && this._handleMiddleLinkTouchHandlerForIOS( data ) ) {
+ middleClickHandled = true;
+ }
+
+ // If the touch event was not handled by the middle link touch handler, proceed
+ // with the default behavior of two-step caret movement.
+ if ( !middleClickHandled ) {
+ clicked = false;
+ touched = true;
+ }
} );
// Track mouse click event.
@@ -563,6 +581,75 @@ export default class TwoStepCaretMovement extends Plugin {
} );
}
+ /**
+ * Enables special handling for middle-link touches on iOS devices.
+ * When a user touches the middle part of a link (not on edges), the cursor
+ * will be positioned at the end of the link to allow easy typing after the link.
+ *
+ * This is iOS-specific behavior to improve the user experience when working with links.
+ *
+ * See: https://github.com/ckeditor/ckeditor5/issues/18023
+ *
+ * @param data The touch event data.
+ * @returns `true` if the touch was handled and the selection was moved.
+ */
+ private _handleMiddleLinkTouchHandlerForIOS( data: DomEventData ): boolean {
+ const { editor } = this;
+
+ // Get the view element directly from the event data.
+ const targetViewElement = data.target;
+
+ // Find the closest link element (could be the target itself or one of its ancestors).
+ let twoStepCaretElement: ViewElement | null = null;
+
+ console.info( targetViewElement );
+
+ if ( isTwoStepCaretElement( targetViewElement ) ) {
+ twoStepCaretElement = targetViewElement;
+ } else {
+ twoStepCaretElement = targetViewElement.getAncestors().find( isTwoStepCaretElement ) as ViewElement | null;
+ }
+
+ // If no link element found, exit early.
+ if ( !twoStepCaretElement ) {
+ return false;
+ }
+
+ // Check if touch happened in the middle of the link.
+ const domElement = editor.editing.view.domConverter.mapViewToDom( twoStepCaretElement )!;
+ const rect = domElement.getBoundingClientRect();
+
+ const { clientX, clientY } = data.domEvent.touches[ 0 ];
+
+ // Define edge threshold in pixels for X axis only.
+ const edgeThresholdPx = 10;
+
+ // Consider it a middle click if:
+ // 1. Not on left or right edge (with threshold).
+ const isNotLeftEdge = clientX > ( rect.left + edgeThresholdPx );
+ const isNotRightEdge = clientX < ( rect.right - edgeThresholdPx );
+
+ // 2. Vertically within the link boundaries (no threshold).
+ const isVerticallyInside = clientY >= rect.top && clientY <= rect.bottom;
+
+ const isMiddleLinkClick = isNotLeftEdge && isNotRightEdge && isVerticallyInside;
+
+ // If not a middle click, exit early.
+ if ( !isMiddleLinkClick ) {
+ return false;
+ }
+
+ // Set the selection to the end of the link.
+ editor.model.change( writer => {
+ const viewRange = editor.editing.view.createPositionAt( twoStepCaretElement!, 'end' );
+ const modelPosition = editor.editing.mapper.toModelPosition( viewRange );
+
+ writer.setSelection( modelPosition );
+ } );
+
+ return true;
+ }
+
/**
* 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 two-step node after inserting the content.
@@ -782,3 +869,20 @@ function isBetweenDifferentAttributes( position: Position, attributes: Set {
- let editor, model, emitter, selection, view, plugin;
+ let editor, model, emitter, selection, view, plugin, element;
let preventDefaultSpy, evtStopSpy;
testUtils.createSinonSandbox();
beforeEach( () => {
emitter = Object.create( DomEmitterMixin );
+ element = document.body.appendChild(
+ document.createElement( 'div' )
+ );
- return VirtualTestEditor.create( {
- plugins: [ TwoStepCaretMovement, Input, Delete ]
- } ).then( newEditor => {
- editor = newEditor;
- model = editor.model;
- selection = model.document.selection;
- view = editor.editing.view;
- plugin = editor.plugins.get( TwoStepCaretMovement );
-
- preventDefaultSpy = sinon.spy().named( 'preventDefault' );
- evtStopSpy = sinon.spy().named( 'evt.stop' );
-
- editor.model.schema.extend( '$text', {
- allowAttributes: [ 'a', 'b', 'c' ],
- allowIn: '$root'
- } );
- editor.model.schema.register( 'inlineObject', {
- inheritAllFrom: '$inlineObject',
- allowAttributes: [ 'src' ]
- } );
-
- model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
- editor.conversion.for( 'upcast' ).elementToAttribute( { view: 'a', model: 'a' } );
- editor.conversion.for( 'upcast' ).elementToAttribute( { view: 'b', model: 'b' } );
- editor.conversion.for( 'upcast' ).elementToAttribute( { view: 'c', model: 'c' } );
- editor.conversion.elementToElement( { model: 'paragraph', view: 'p' } );
- editor.conversion.elementToElement( { model: 'inlineObject', view: 'inlineObject' } );
- editor.conversion.attributeToAttribute( { model: 'src', view: 'src' } );
-
- plugin.registerAttribute( 'a' );
- } );
+ return createEditor();
} );
- afterEach( () => {
- return editor.destroy();
+ afterEach( async () => {
+ await editor.destroy();
+ element.remove();
} );
it( 'should have `isOfficialPlugin` static flag set to `true`', () => {
@@ -1427,6 +1405,284 @@ describe( 'TwoStepCaretMovement', () => {
} );
} );
+ describe( '_enableMiddleLinkTouchHandlerForIOS', () => {
+ let iosEnvironmentStub;
+
+ beforeEach( async () => {
+ iosEnvironmentStub = sinon.stub( env, 'isiOS' ).value( true );
+
+ await editor.destroy();
+ await createEditor();
+ } );
+
+ afterEach( () => {
+ if ( iosEnvironmentStub ) {
+ iosEnvironmentStub.restore();
+ }
+
+ return editor.destroy();
+ } );
+
+ it( 'should set selection to the end of link when touched in the middle', () => {
+ // Set initial content.
+ setModelData( model, 'foo <$text linkHref="https://example.com">link$text>[] bar' );
+
+ // Create a mock link view element.
+ const root = view.document.getRoot();
+ const paragraph = root.getChild( 0 );
+ const linkViewElement = paragraph.getChild( 1 );
+
+ // Stub the DOM converter to return our mock element.
+ const linkElement = editor.editing.view.domConverter.mapViewToDom( linkViewElement );
+
+ sinon.stub( linkElement, 'getBoundingClientRect' ).returns( {
+ left: 100,
+ right: 150,
+ top: 50,
+ bottom: 70
+ } );
+
+ // Initial position before the test
+ model.change( writer => {
+ writer.setSelection( model.createPositionAt( model.document.getRoot().getChild( 0 ), 2 ) );
+ } );
+
+ // Create a touch event in the middle of the link.
+ const touchStartData = new DomEventData(
+ view,
+ {
+ target: linkElement,
+ touches: [
+ { clientX: 125, clientY: 60 }
+ ]
+ }
+ );
+
+ view.document.fire( 'touchstart', touchStartData );
+
+ // Verify selection was moved to the end of the link
+ expect( getModelData( model ) ).to.equal(
+ 'foo <$text linkHref="https://example.com">link[]$text> bar'
+ );
+ } );
+
+ it( 'should not change selection when touched on left edge of link', () => {
+ setModelData( model, 'foo <$text linkHref="https://example.com">link$text> bar[]' );
+
+ const root = view.document.getRoot();
+ const paragraph = root.getChild( 0 );
+ const linkViewElement = paragraph.getChild( 1 );
+ const linkElement = editor.editing.view.domConverter.mapViewToDom( linkViewElement );
+
+ sinon.stub( linkElement, 'getBoundingClientRect' ).returns( {
+ left: 100,
+ right: 150,
+ top: 50,
+ bottom: 70
+ } );
+
+ // Touch on left edge (within edge threshold).
+ const touchStartData = new DomEventData(
+ view,
+ {
+ target: linkElement,
+ touches: [
+ { clientX: 105, clientY: 60 }
+ ]
+ }
+ );
+
+ view.document.fire( 'touchstart', touchStartData );
+
+ // Selection should not change
+ expect( getModelData( model ) ).to.equal(
+ 'foo <$text linkHref="https://example.com">link$text> bar[]'
+ );
+ } );
+
+ it( 'should not change selection when touched on right edge of link', () => {
+ setModelData( model, 'foo <$text linkHref="https://example.com">link$text> bar[]' );
+
+ const root = view.document.getRoot();
+ const paragraph = root.getChild( 0 );
+ const linkViewElement = paragraph.getChild( 1 );
+ const linkElement = editor.editing.view.domConverter.mapViewToDom( linkViewElement );
+
+ sinon.stub( linkElement, 'getBoundingClientRect' ).returns( {
+ left: 100,
+ right: 150,
+ top: 50,
+ bottom: 70
+ } );
+
+ // Touch on right edge (within edge threshold).
+ const touchStartData = new DomEventData(
+ view,
+ {
+ target: linkElement,
+ touches: [
+ { clientX: 145, clientY: 60 }
+ ]
+ }
+ );
+
+ view.document.fire( 'touchstart', touchStartData );
+
+ // Selection should not change.
+ expect( getModelData( model ) ).to.equal(
+ 'foo <$text linkHref="https://example.com">link$text> bar[]'
+ );
+ } );
+
+ it( 'should not change selection when touch vertically outside the link boundaries', () => {
+ setModelData( model, 'foo <$text linkHref="https://example.com">link$text> bar[]' );
+
+ const root = view.document.getRoot();
+ const paragraph = root.getChild( 0 );
+ const linkViewElement = paragraph.getChild( 1 );
+ const linkElement = editor.editing.view.domConverter.mapViewToDom( linkViewElement );
+
+ sinon.stub( linkElement, 'getBoundingClientRect' ).returns( {
+ left: 100,
+ right: 150,
+ top: 50,
+ bottom: 70
+ } );
+
+ // Touch above the link
+ const touchStartData = new DomEventData(
+ view,
+ {
+ target: linkElement,
+ touches: [
+ { clientX: 125, clientY: 40 }
+ ]
+ }
+ );
+
+ view.document.fire( 'touchstart', touchStartData );
+
+ // Selection should not change.
+ expect( getModelData( model ) ).to.equal(
+ 'foo <$text linkHref="https://example.com">link$text> bar[]'
+ );
+ } );
+
+ it( 'should work with nested elements inside link', () => {
+ // Create paragraph with link that has nested bold text.
+ editor.setData( 'foo bold link bar
' );
+
+ // Set initial selection somewhere else.
+ model.change( writer => {
+ writer.setSelection( model.createPositionAt( model.document.getRoot().getChild( 0 ), 0 ) );
+ } );
+
+ // Get the nested strong element inside the link.
+ const root = view.document.getRoot();
+ const paragraph = root.getChild( 0 );
+ const linkViewElement = paragraph.getChild( 1 );
+ const strongElement = linkViewElement.getChild( 0 );
+
+ // Get DOM elements
+ const strongDomElement = editor.editing.view.domConverter.mapViewToDom( strongElement );
+ const linkElement = editor.editing.view.domConverter.mapViewToDom( linkViewElement );
+
+ sinon.stub( linkElement, 'getBoundingClientRect' ).returns( {
+ left: 100,
+ right: 180,
+ top: 50,
+ bottom: 70
+ } );
+
+ // Touch in the middle of the bold text (which is inside the link).
+ const touchStartData = new DomEventData(
+ view,
+ {
+ target: strongDomElement,
+ touches: [
+ { clientX: 140, clientY: 60 }
+ ]
+ }
+ );
+
+ view.document.fire( 'touchstart', touchStartData );
+
+ // Verify selection was moved to the end of the link, even though we touched the nested element.
+ expect( getModelData( model ) ).to.include( 'bold link[]$text>' );
+ } );
+
+ it( 'should not affect selection when touching non-link elements', () => {
+ setModelData( model, 'foo <$text bold="true">bold link$text> ba[]r' );
+
+ const root = view.document.getRoot();
+ const paragraph = root.getChild( 0 );
+ const strongNode = paragraph.getChild( 1 );
+ const strongElement = editor.editing.view.domConverter.mapViewToDom( strongNode );
+
+ // Touch on regular text
+ const touchStartData = new DomEventData(
+ view,
+ {
+ target: strongElement,
+ touches: [
+ { clientX: 50, clientY: 60 }
+ ]
+ }
+ );
+
+ view.document.fire( 'touchstart', touchStartData );
+
+ // Selection should remain unchanged.
+ expect( getModelData( model ) ).to.equal(
+ 'foo <$text bold="true">bold link$text> ba[]r'
+ );
+ } );
+
+ it( 'should correctly handle multiple links and set selection to the end of the touched link', () => {
+ setModelData( model,
+ '' +
+ '<$text linkHref="https://example1.com">first$text> ' +
+ '<$text linkHref="https://example2.com">second$text> ' +
+ '<$text linkHref="https://example3.com">third$text>[]' +
+ ''
+ );
+
+ const root = view.document.getRoot();
+ const paragraph = root.getChild( 0 );
+ const secondLinkViewElement = paragraph.getChild( 2 );
+ const secondLinkElement = editor.editing.view.domConverter.mapViewToDom( secondLinkViewElement );
+
+ sinon.stub( secondLinkElement, 'getBoundingClientRect' ).returns( {
+ left: 150,
+ right: 220,
+ top: 50,
+ bottom: 70
+ } );
+
+ // Touch in the middle of the second link
+ const touchStartData = new DomEventData(
+ view,
+ {
+ target: secondLinkElement,
+ touches: [
+ { clientX: 185, clientY: 60 }
+ ]
+ }
+ );
+
+ view.document.fire( 'touchstart', touchStartData );
+
+ // Verify selection was moved to the end of the second link.
+ expect( getModelData( model ) ).to.equal(
+ '' +
+ '<$text linkHref="https://example1.com">first$text> ' +
+ '<$text linkHref="https://example2.com">second[]$text> ' +
+ '<$text linkHref="https://example3.com">third$text>' +
+ ''
+ );
+ } );
+ } );
+
const keyMap = {
'→': 'arrowright',
'←': 'arrowleft'
@@ -1521,4 +1777,39 @@ describe( 'TwoStepCaretMovement', () => {
}
}
}
+
+ async function createEditor() {
+ editor = await ClassicTestEditor.create( element, {
+ plugins: [ TwoStepCaretMovement, Input, Delete, Link, Bold ]
+ } );
+
+ model = editor.model;
+ selection = model.document.selection;
+ view = editor.editing.view;
+ plugin = editor.plugins.get( TwoStepCaretMovement );
+
+ preventDefaultSpy = sinon.spy().named( 'preventDefault' );
+ evtStopSpy = sinon.spy().named( 'evt.stop' );
+
+ editor.model.schema.extend( '$text', {
+ allowAttributes: [ 'a', 'b', 'c' ],
+ allowIn: '$root'
+ } );
+ editor.model.schema.register( 'inlineObject', {
+ inheritAllFrom: '$inlineObject',
+ allowAttributes: [ 'src' ]
+ } );
+
+ model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
+ editor.conversion.for( 'upcast' ).elementToAttribute( { view: 'a', model: 'a' } );
+ editor.conversion.for( 'upcast' ).elementToAttribute( { view: 'b', model: 'b' } );
+ editor.conversion.for( 'upcast' ).elementToAttribute( { view: 'c', model: 'c' } );
+ editor.conversion.elementToElement( { model: 'paragraph', view: 'p' } );
+ editor.conversion.elementToElement( { model: 'inlineObject', view: 'inlineObject' } );
+ editor.conversion.attributeToAttribute( { model: 'src', view: 'src' } );
+
+ plugin.registerAttribute( 'a' );
+
+ return editor;
+ }
} );