Skip to content

Commit

Permalink
Merge pull request #17481 from ckeditor/ck/17171
Browse files Browse the repository at this point in the history
Fix (typing): Fix not working two-step caret movement on iOS devices. Closes #17171
  • Loading branch information
Mati365 authored Dec 12, 2024
2 parents 9fe9a58 + 7423795 commit e228617
Show file tree
Hide file tree
Showing 5 changed files with 196 additions and 3 deletions.
6 changes: 6 additions & 0 deletions packages/ckeditor5-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export { default as ClickObserver } from './view/observer/clickobserver.js';
export { default as DomEventObserver } from './view/observer/domeventobserver.js';
export { default as MouseObserver } from './view/observer/mouseobserver.js';
export { default as TabObserver } from './view/observer/tabobserver.js';
export { default as TouchObserver } from './view/observer/touchobserver.js';

export {
default as FocusObserver,
Expand Down Expand Up @@ -197,6 +198,11 @@ export type {
ViewDocumentMouseOverEvent,
ViewDocumentMouseOutEvent
} from './view/observer/mouseobserver.js';
export type {
ViewDocumentTouchEndEvent,
ViewDocumentTouchMoveEvent,
ViewDocumentTouchStartEvent
} from './view/observer/touchobserver.js';
export type { ViewDocumentTabEvent } from './view/observer/tabobserver.js';
export type { ViewDocumentClickEvent } from './view/observer/clickobserver.js';
export type { ViewDocumentSelectionChangeEvent } from './view/observer/selectionobserver.js';
Expand Down
82 changes: 82 additions & 0 deletions packages/ckeditor5-engine/src/view/observer/touchobserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/

/**
* @module engine/view/observer/touchobserver
*/

import DomEventObserver from './domeventobserver.js';
import type DomEventData from './domeventdata.js';

/**
* Touch events observer.
*
* Note that this observer is not available by default. To make it available it needs to be added to
* {@link module:engine/view/view~View} by {@link module:engine/view/view~View#addObserver} method.
*/
export default class TouchObserver extends DomEventObserver<'touchstart' | 'touchend' | 'touchmove'> {
/**
* @inheritDoc
*/
public readonly domEventType = [ 'touchstart', 'touchend', 'touchmove' ] as const;

/**
* @inheritDoc
*/
public onDomEvent( domEvent: TouchEvent ): void {
this.fire( domEvent.type, domEvent );
}
}

/**
* Fired when a touch is started on one of the editing roots of the editor.
*
* Introduced by {@link module:engine/view/observer/touchobserver~TouchObserver}.
*
* Note that this event is not available by default. To make it available, {@link module:engine/view/observer/touchobserver~TouchObserver}
* needs to be added to {@link module:engine/view/view~View} by the {@link module:engine/view/view~View#addObserver} method.
*
* @see module:engine/view/observer/touchobserver~TouchObserver
* @eventName module:engine/view/document~Document#touchstart
* @param data The event data.
*/
export type ViewDocumentTouchStartEvent = {
name: 'touchstart';
args: [ data: DomEventData<TouchEvent> ];
};

/**
* Fired when a touch ends on one of the editing roots of the editor.
*
* Introduced by {@link module:engine/view/observer/touchobserver~TouchObserver}.
*
* Note that this event is not available by default. To make it available, {@link module:engine/view/observer/touchobserver~TouchObserver}
* needs to be added to {@link module:engine/view/view~View} by the {@link module:engine/view/view~View#addObserver} method.
*
* @see module:engine/view/observer/touchobserver~TouchObserver
* @eventName module:engine/view/document~Document#touchend
* @param data The event data.
*/
export type ViewDocumentTouchEndEvent = {
name: 'touchend';
args: [ data: DomEventData<TouchEvent> ];
};

/**
* Fired when a touch is moved on one of the editing roots of the editor.
*
* Introduced by {@link module:engine/view/observer/touchobserver~TouchObserver}.
*
* Note that this event is not available by default. To make it available, {@link module:engine/view/observer/touchobserver~TouchObserver}
* needs to be added to {@link module:engine/view/view~View} by the {@link module:engine/view/view~View#addObserver} method.
*
* @see module:engine/view/observer/touchobserver~TouchObserver
* @eventName module:engine/view/document~Document#touchmove
* @param data The event data.
*/
export type ViewDocumentTouchMoveEvent = {
name: 'touchmove';
args: [ data: DomEventData<TouchEvent> ];
};
69 changes: 69 additions & 0 deletions packages/ckeditor5-engine/tests/view/observer/touchobserver.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/

/* globals document */

import TouchObserver from '../../../src/view/observer/touchobserver.js';
import View from '../../../src/view/view.js';
import { StylesProcessor } from '../../../src/view/stylesmap.js';

describe( 'TouchObserver', () => {
let view, viewDocument, observer;

beforeEach( () => {
view = new View( new StylesProcessor() );
viewDocument = view.document;
observer = view.addObserver( TouchObserver );
} );

afterEach( () => {
view.destroy();
} );

it( 'should define domEventType', () => {
expect( observer.domEventType ).to.deep.equal( [ 'touchstart', 'touchend', 'touchmove' ] );
} );

describe( 'onDomEvent', () => {
it( 'should fire touchstart with the right event data', () => {
const spy = sinon.spy();

viewDocument.on( 'touchstart', spy );

observer.onDomEvent( { type: 'touchstart', target: document.body } );

expect( spy.calledOnce ).to.be.true;

const data = spy.args[ 0 ][ 1 ];
expect( data.domTarget ).to.equal( document.body );
} );

it( 'should fire touchend with the right event data', () => {
const spy = sinon.spy();

viewDocument.on( 'touchend', spy );

observer.onDomEvent( { type: 'touchend', target: document.body } );

expect( spy.calledOnce ).to.be.true;

const data = spy.args[ 0 ][ 1 ];
expect( data.domTarget ).to.equal( document.body );
} );

it( 'should fire touchmove with the right event data', () => {
const spy = sinon.spy();

viewDocument.on( 'touchmove', spy );

observer.onDomEvent( { type: 'touchmove', target: document.body } );

expect( spy.calledOnce ).to.be.true;

const data = spy.args[ 0 ][ 1 ];
expect( data.domTarget ).to.equal( document.body );
} );
} );
} );
21 changes: 18 additions & 3 deletions packages/ckeditor5-typing/src/twostepcaretmovement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { keyCodes } from '@ckeditor/ckeditor5-utils';

import {
MouseObserver,
TouchObserver,
type DocumentSelection,
type DocumentSelectionChangeRangeEvent,
type DomEventData,
Expand All @@ -21,6 +22,7 @@ import {
type ViewDocumentArrowKeyEvent,
type ViewDocumentMouseDownEvent,
type ViewDocumentSelectionChangeEvent,
type ViewDocumentTouchStartEvent,
type ModelInsertContentEvent,
type ModelDeleteContentEvent
} from '@ckeditor/ckeditor5-engine';
Expand Down Expand Up @@ -481,10 +483,22 @@ export default class TwoStepCaretMovement extends Plugin {
const document = editor.editing.view.document;

editor.editing.view.addObserver( MouseObserver );
editor.editing.view.addObserver( TouchObserver );

let touched = false;
let clicked = false;

// Detect the click.
// This event should be fired before selection on mobile devices.
this.listenTo<ViewDocumentTouchStartEvent>( document, 'touchstart', () => {
clicked = false;
touched = true;
} );

// Track mouse click event.
// Keep in mind that it's often called after the selection change on iOS devices.
// On the Android devices, it's called before the selection change.
// That's why we watch `touchstart` event on mobile and set `touched` flag, as it's fired before the selection change.
// See more: https://github.com/ckeditor/ckeditor5/issues/17171
this.listenTo<ViewDocumentMouseDownEvent>( document, 'mousedown', () => {
clicked = true;
} );
Expand All @@ -493,12 +507,13 @@ export default class TwoStepCaretMovement extends Plugin {
this.listenTo<ViewDocumentSelectionChangeEvent>( document, 'selectionChange', () => {
const attributes = this.attributes;

if ( !clicked ) {
if ( !clicked && !touched ) {
return;
}

// ...and it was caused by the click...
// ...and it was caused by the click or touch...
clicked = false;
touched = false;

// ...and no text is selected...
if ( !selection.isCollapsed ) {
Expand Down
21 changes: 21 additions & 0 deletions packages/ckeditor5-typing/tests/twostepcaretmovement.js
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,27 @@ describe( 'TwoStepCaretMovement', () => {

expect( getModelData( model ) ).to.equal( '<paragraph><$text bold="true">foo[]</$text></paragraph>' );
} );

// https://github.com/ckeditor/ckeditor5/issues/17171
it( 'should handle use touchstart event to determine behavior if mousedown is fired after selectionchange on iOS', () => {
setModelData( model, '<paragraph><$text a="1">foo[]</$text></paragraph>' );

editor.editing.view.document.fire( 'touchstart' );
editor.editing.view.document.fire( 'selectionChange', {
newSelection: view.document.selection
} );

// on safari the mousedown event is called after selectionchange, so we can simulate it here
editor.editing.view.document.fire( 'mousedown' );

expect( getModelData( model ) ).to.equal( '<paragraph><$text a="1">foo</$text>[]</paragraph>' );

model.change( writer => {
model.insertContent( writer.createText( 'bar', selection.getAttributes() ), selection.getFirstPosition() );
} );

expect( getModelData( model ) ).to.equal( '<paragraph><$text a="1">foo</$text>bar[]</paragraph>' );
} );
} );

// https://github.com/ckeditor/ckeditor5/issues/6053
Expand Down

0 comments on commit e228617

Please sign in to comment.