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{}baz + // foo<$text attribute>bar{}baz // // 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[] 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[] bar' + ); + } ); + + it( 'should not change selection when touched on left edge of link', () => { + setModelData( model, 'foo <$text linkHref="https://example.com">link 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 bar[]' + ); + } ); + + it( 'should not change selection when touched on right edge of link', () => { + setModelData( model, 'foo <$text linkHref="https://example.com">link 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 bar[]' + ); + } ); + + it( 'should not change selection when touch vertically outside the link boundaries', () => { + setModelData( model, 'foo <$text linkHref="https://example.com">link 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 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[]' ); + } ); + + it( 'should not affect selection when touching non-link elements', () => { + setModelData( model, 'foo <$text bold="true">bold link 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 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 linkHref="https://example2.com">second ' + + '<$text linkHref="https://example3.com">third[]' + + '' + ); + + 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 linkHref="https://example2.com">second[] ' + + '<$text linkHref="https://example3.com">third' + + '' + ); + } ); + } ); + 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; + } } );