Skip to content

Commit

Permalink
Feature (engine): Elements with data-cke-ignore-events attribute wi…
Browse files Browse the repository at this point in the history
…ll not propagate it's events to CKEditor API. Closes #4600.
  • Loading branch information
mlewand authored Oct 20, 2020
2 parents 608baa9 + bd97c26 commit 04207f9
Show file tree
Hide file tree
Showing 13 changed files with 481 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ While it is not strictly necessary to read the {@link framework/guides/quick-sta

The tutorial will also reference various parts of the {@link framework/guides/architecture/intro CKEditor 5 architecture} section as you go. While reading them is not necessary to finish this tutorial, it is recommended to read these guides at some point to get a better understanding of the mechanisms used in this tutorial.

<info-box>
If you want to use own event handler for events triggered by your widget then you must wrap it by a container that has a `data-cke-ignore-events` attribute to exclude it from editor's default handlers. Refer to {@link framework/guides/deep-dive/widget-internals#exclude-dom-events-from-default-handlers Exclude DOM events from default handlers} for more details.
</info-box>

## Let's start

This guide assumes that you are familiar with npm and your project uses npm already. If not, see the [npm documentation](https://docs.npmjs.com/getting-started/what-is-npm) or call `npm init` in an empty directory and keep your fingers crossed.
Expand Down
4 changes: 4 additions & 0 deletions docs/framework/guides/tutorials/using-react-in-a-widget.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ There are a couple of things you should know before you start:
* Also, while it is not strictly necessary to read the {@link framework/guides/quick-start Quick start} guide before going through this tutorial, it may help you to get more comfortable with CKEditor 5 Framework before you dive into this tutorial.
* Various parts of the {@link framework/guides/architecture/intro CKEditor 5 architecture} section will be referenced as you go. While reading them is not necessary to finish this tutorial, it is recommended to read those guides at some point to get a better understanding of the mechanisms used in this tutorial.

<info-box>
If you want to use own event handler for events triggered by your React component then you must wrap it by a container that has a `data-cke-ignore-events` attribute to exclude it from editor's default handlers. Refer to {@link framework/guides/deep-dive/widget-internals#exclude-dom-events-from-default-handlers Exclude DOM events from default handlers} for more details.
</info-box>

## Let's start

This guide assumes that you are familiar with [yarn](https://yarnpkg.com) and your project uses yarn already. If not, see the [yarn documentation](https://yarnpkg.com/en/docs/getting-started). If you are using [npm](https://www.npmjs.com/get-npm) you do not have to worry — you can perform the same installation tasks just as easily using [corresponding npm commands](https://docs.npmjs.com/getting-packages-from-the-registry).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export default class DomEventObserver extends Observer {

types.forEach( type => {
this.listenTo( domElement, type, ( eventInfo, domEvent ) => {
if ( this.isEnabled ) {
if ( this.isEnabled && !this.checkShouldIgnoreEventFromTarget( domEvent.target ) ) {
this.onDomEvent( domEvent );
}
}, { useCapture: this.useCapture } );
Expand Down
24 changes: 24 additions & 0 deletions packages/ckeditor5-engine/src/view/observer/observer.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,30 @@ export default class Observer {
this.stopListening();
}

/**
* Checks whether the given DOM event should be ignored (should not be turned into a synthetic view document event).
*
* Currently, an event will be ignored only if its target or any of its ancestors has the `data-cke-ignore-events` attribute.
* This attribute can be used inside structures generated by
* {@link module:engine/view/downcastwriter~DowncastWriter#createUIElement `DowncastWriter#createUIElement()`} to ignore events
* fired within a UI that should be excluded from CKEditor 5's realms.
*
* @param {Node} domTarget The DOM event target to check (usually an element, sometimes a text node and
* potentially sometimes a document too).
* @returns {Boolean} Whether this event should be ignored by the observer.
*/
checkShouldIgnoreEventFromTarget( domTarget ) {
if ( domTarget && domTarget.nodeType === 3 ) {
domTarget = domTarget.parentNode;
}

if ( !domTarget || domTarget.nodeType !== 1 ) {
return false;
}

return domTarget.matches( '[data-cke-ignore-events], [data-cke-ignore-events] *' );
}

/**
* Starts observing the given root element.
*
Expand Down
14 changes: 10 additions & 4 deletions packages/ckeditor5-engine/src/view/observer/selectionobserver.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,8 @@ export default class SelectionObserver extends Observer {
return;
}

this.listenTo( domDocument, 'selectionchange', () => {
this._handleSelectionChange( domDocument );
this.listenTo( domDocument, 'selectionchange', ( evt, domEvent ) => {
this._handleSelectionChange( domEvent, domDocument );
} );

this._documents.add( domDocument );
Expand All @@ -123,19 +123,25 @@ export default class SelectionObserver extends Observer {
* and {@link module:engine/view/document~Document#event:selectionChangeDone} when selection stop changing.
*
* @private
* @param {Event} domEvent DOM event.
* @param {Document} domDocument DOM document.
*/
_handleSelectionChange( domDocument ) {
_handleSelectionChange( domEvent, domDocument ) {
if ( !this.isEnabled ) {
return;
}

const domSelection = domDocument.defaultView.getSelection();

if ( this.checkShouldIgnoreEventFromTarget( domSelection.anchorNode ) ) {
return;
}

// Ensure the mutation event will be before selection event on all browsers.
this.mutationObserver.flush();

// If there were mutations then the view will be re-rendered by the mutation observer and selection
// will be updated, so selections will equal and event will not be fired, as expected.
const domSelection = domDocument.defaultView.getSelection();
const newViewSelection = this.domConverter.domSelectionToView( domSelection );

// Do not convert selection change if the new view selection has no ranges in it.
Expand Down
41 changes: 41 additions & 0 deletions packages/ckeditor5-engine/tests/manual/tickets/4600/1.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<style>
.simple-widget-container {
margin: 1em 0;
font-family: sans-serif;
}

.simple-widget-element {
display: flex;
height: 14em;
}

.simple-widget-element>fieldset {
display: flex;
align-items: center;
margin: 3em;
width: 50%;
border: 1px solid #ddd;
border-radius: 4px;
}

.simple-widget-element>fieldset>legend {
font-size: 0.85em;
padding: .2em 2em;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
}

.simple-widget-element>fieldset>input {
flex: 1;
margin: 1em 1em 1em 0;
}

.simple-widget-element>fieldset>button {
padding: .2em 3em;
}
</style>

<div id="editor">
<section class="simple-widget-container" />
</div>
155 changes: 155 additions & 0 deletions packages/ckeditor5-engine/tests/manual/tickets/4600/1.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/

/* globals console, document, window, Event */

import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';

import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import Widget from '@ckeditor/ckeditor5-widget/src/widget';
import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';

import ClickObserver from '../../../../src/view/observer/clickobserver';
import CompositionObserver from '../../../../src/view/observer/compositionobserver';
import FocusObserver from '../../../../src/view/observer/focusobserver';
import InputObserver from '../../../../src/view/observer/inputobserver';
import KeyObserver from '../../../../src/view/observer/keyobserver';
import MouseObserver from '../../../../src/view/observer/mouseobserver';
import MouseEventsObserver from '@ckeditor/ckeditor5-table/src/tablemouse/mouseeventsobserver';

class SimpleWidgetEditing extends Plugin {
static get requires() {
return [ Widget ];
}

init() {
this._defineSchema();
this._defineConverters();
this._addObservers();
}

_defineSchema() {
const schema = this.editor.model.schema;

schema.register( 'simpleWidgetElement', {
inheritAllFrom: '$block',
isObject: true
} );
}

_defineConverters() {
const conversion = this.editor.conversion;

conversion.for( 'editingDowncast' ).elementToElement( {
model: 'simpleWidgetElement',
view: ( modelElement, { writer } ) => {
const widgetElement = createWidgetView( modelElement, { writer } );

return toWidget( widgetElement, writer );
}
} );

conversion.for( 'dataDowncast' ).elementToElement( {
model: 'simpleWidgetElement',
view: createWidgetView
} );

conversion.for( 'upcast' ).elementToElement( {
model: 'simpleWidgetElement',
view: {
name: 'section',
classes: 'simple-widget-container'
}
} );

function createWidgetView( modelElement, { writer } ) {
const simpleWidgetContainer = writer.createContainerElement( 'section', { class: 'simple-widget-container' } );
const simpleWidgetElement = writer.createRawElement( 'div', { class: 'simple-widget-element' }, domElement => {
domElement.innerHTML = `
<fieldset data-cke-ignore-events="true">
<legend>Ignored container with <strong>data-cke-ignore-events="true"</strong></legend>
<input>
<button>Click!</button>
</fieldset>
<fieldset>
<legend>Regular container</legend>
<input>
<button>Click!</button>
</fieldset>
`;
} );

writer.insert( writer.createPositionAt( simpleWidgetContainer, 0 ), simpleWidgetElement );

return simpleWidgetContainer;
}
}

_addObservers() {
const view = this.editor.editing.view;

const observers = new Map( [
[ ClickObserver, [ 'click' ] ],
[ CompositionObserver, [ 'compositionstart', 'compositionupdate', 'compositionend' ] ],
[ FocusObserver, [ 'focus', 'blur' ] ],
[ InputObserver, [ 'beforeinput' ] ],
[ KeyObserver, [ 'keydown', 'keyup' ] ],
[ MouseEventsObserver, [ 'mousemove', 'mouseup', 'mouseleave' ] ],
[ MouseObserver, [ 'mousedown' ] ]
] );

observers.forEach( ( events, observer ) => {
view.addObserver( observer );

events.forEach( eventName => {
this.listenTo( view.document, eventName, () => {
console.log( `Received ${ eventName } event.` );
} );
} );
} );
}
}

class SimpleWidgetUI extends Plugin {}

class SimpleWidget extends Plugin {
static get requires() {
return [ SimpleWidgetEditing, SimpleWidgetUI ];
}
}

ClassicEditor
.create( document.querySelector( '#editor' ), {
plugins: [ Essentials, SimpleWidget ]
} )
.then( editor => {
window.editor = editor;
addEventDispatcherForButtons( editor, 'click' );
} )
.catch( error => {
console.error( error.stack );
} );

function addEventDispatcherForButtons( editor, eventName ) {
const view = editor.editing.view;
const container = Array
.from( view.document.getRoot().getChildren() )
.find( element => element.hasClass( 'simple-widget-container' ) );

view.domConverter
.viewToDom( container )
.querySelectorAll( 'button' )
.forEach( button => {
button.addEventListener( 'click', event => {
if ( !event.isTrusted ) {
return;
}

console.log( `Dispatched ${ eventName } event.` );
button.dispatchEvent( new Event( eventName, { bubbles: true } ) );
} );
} );
}
19 changes: 19 additions & 0 deletions packages/ckeditor5-engine/tests/manual/tickets/4600/1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
### Ignoring events fired by certain elements [#4600](https://github.com/ckeditor/ckeditor5/issues/4600)

Events are logged in console.

### Case 1: Events are ignored and they are not handled by default listeners.
1. Move mouse cursor over a left container named `Ignored container with data-cke-ignore-events="true"`.
2. When it is already there, start moving it around within container boundaries, start typing in text field and start clicking on text field and button.
3. Click on the button.

**Expected results**: Only then, when the mouse cursor is over the left container, new logs will stop appearing in the console. Clicking inside it, typing in text field and moving mouse cursor inside the container boundaries should not be logged in console. Clicking on a button dispatches the `click` event (the `Dispatched click event` message should be logged), but `Received click event` shouldn't be present in console.

One note for `focus` nad `blur` events: they will be logged in console, but only when container lost focus or gets it back, respectively.

### Case 2: Events are not ignored and they are handled by default listeners.
1. Move mouse cursor over a right container named `Regular container`.
2. When it is already there, start moving it around within container boundaries, start typing in text field and start clicking on text field and button.
3. Click on the button.

**Expected results**: Events should be logged in console. It shouldn't be possible to focus the text field and type anything there. Clicking on a button dispatches the `click` event (the `Dispatched click event` message should be logged) and doubled `Received click event` should be present in console: one from "real" user mouse click and second one from script-generated `click` event.
22 changes: 22 additions & 0 deletions packages/ckeditor5-engine/tests/view/observer/domeventobserver.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,28 @@ describe( 'DomEventObserver', () => {
expect( evtSpy.called ).to.be.false;
} );

it( 'should not fire event if target is ignored', () => {
const domElement = document.createElement( 'p' );
const domEvent = new MouseEvent( 'click' );
const evtSpy = sinon.spy();

const ignoreEventFromTargetStub = sinon
.stub( Observer.prototype, 'checkShouldIgnoreEventFromTarget' )
.returns( true );

createViewRoot( viewDocument );
view.attachDomRoot( domElement );
view.addObserver( ClickObserver );
viewDocument.on( 'click', evtSpy );

domElement.dispatchEvent( domEvent );

expect( ignoreEventFromTargetStub.called ).to.be.true;
expect( evtSpy.called ).to.be.false;

ignoreEventFromTargetStub.restore();
} );

it( 'should fire event if observer is disabled and re-enabled', () => {
const domElement = document.createElement( 'p' );
const domEvent = new MouseEvent( 'click' );
Expand Down
Loading

0 comments on commit 04207f9

Please sign in to comment.