8
0
Просмотр исходного кода

Merge remote-tracking branch 'origin/i/4600' into i/8204

Kamil Piechaczek 5 лет назад
Родитель
Сommit
880d051ecf

+ 4 - 0
docs/framework/guides/tutorials/implementing-a-block-widget.md

@@ -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.

+ 4 - 0
docs/framework/guides/tutorials/using-react-in-a-widget.md

@@ -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).

+ 1 - 1
packages/ckeditor5-engine/src/view/observer/domeventobserver.js

@@ -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 } );

+ 24 - 0
packages/ckeditor5-engine/src/view/observer/observer.js

@@ -83,6 +83,30 @@ export default class Observer {
 	}
 
 	/**
+	 * 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.
 	 *
 	 * @method #observe

+ 10 - 4
packages/ckeditor5-engine/src/view/observer/selectionobserver.js

@@ -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 );
@@ -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.

+ 41 - 0
packages/ckeditor5-engine/tests/manual/tickets/4600/1.html

@@ -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 - 0
packages/ckeditor5-engine/tests/manual/tickets/4600/1.js

@@ -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 - 0
packages/ckeditor5-engine/tests/manual/tickets/4600/1.md

@@ -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 - 0
packages/ckeditor5-engine/tests/view/observer/domeventobserver.js

@@ -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' );

+ 44 - 0
packages/ckeditor5-engine/tests/view/observer/observer.js

@@ -3,6 +3,8 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
+/* globals document */
+
 import Observer from '../../../src/view/observer/observer';
 import View from '../../../src/view/view';
 import { StylesProcessor } from '../../../src/view/stylesmap';
@@ -44,4 +46,46 @@ describe( 'Observer', () => {
 			expect( observer.isEnabled ).to.be.false;
 		} );
 	} );
+
+	describe( 'checkShouldIgnoreEventFromTarget()', () => {
+		it( 'should not ignore on targets which are non-element node types', () => {
+			const observer = new Observer( {} );
+
+			expect( observer.checkShouldIgnoreEventFromTarget( {} ) ).to.be.false;
+			expect( observer.checkShouldIgnoreEventFromTarget( { nodeType: 2 } ) ).to.be.false;
+			expect( observer.checkShouldIgnoreEventFromTarget( { nodeType: 3 } ) ).to.be.false;
+			expect( observer.checkShouldIgnoreEventFromTarget( { nodeType: 3, parentNode: null } ) ).to.be.false;
+		} );
+
+		it( 'should not ignore on targets without the `data-cke-ignore-events` attribute neither on itself nor in any ancestor', () => {
+			const documentFragment = document.createDocumentFragment();
+			const section = document.createElement( 'section' );
+			const div = document.createElement( 'div' );
+			const button = document.createElement( 'button' );
+
+			documentFragment.appendChild( section ).appendChild( div ).appendChild( button );
+
+			const observer = new Observer( {} );
+
+			expect( observer.checkShouldIgnoreEventFromTarget( section ) ).to.be.false;
+			expect( observer.checkShouldIgnoreEventFromTarget( div ) ).to.be.false;
+			expect( observer.checkShouldIgnoreEventFromTarget( button ) ).to.be.false;
+		} );
+
+		it( 'should ignore on targets with the `data-cke-ignore-events` attribute set on itself or on any ancestor', () => {
+			const documentFragment = document.createDocumentFragment();
+			const section = document.createElement( 'section' );
+			const div = document.createElement( 'div' );
+			const button = document.createElement( 'button' );
+
+			section.setAttribute( 'data-cke-ignore-events', 'true' );
+			documentFragment.appendChild( section ).appendChild( div ).appendChild( button );
+
+			const observer = new Observer( {} );
+
+			expect( observer.checkShouldIgnoreEventFromTarget( section ) ).to.be.true;
+			expect( observer.checkShouldIgnoreEventFromTarget( div ) ).to.be.true;
+			expect( observer.checkShouldIgnoreEventFromTarget( button ) ).to.be.true;
+		} );
+	} );
 } );

+ 12 - 0
packages/ckeditor5-engine/tests/view/observer/selectionobserver.js

@@ -95,6 +95,18 @@ describe( 'SelectionObserver', () => {
 		changeDomSelection();
 	} );
 
+	it( 'should not fire selectionChange for ignored target', done => {
+		viewDocument.on( 'selectionChange', () => {
+			throw 'selectionChange fired in ignored elements';
+		} );
+
+		domMain.childNodes[ 1 ].setAttribute( 'data-cke-ignore-events', 'true' );
+
+		changeDomSelection();
+
+		setTimeout( done, 100 );
+	} );
+
 	it( 'should not fire selectionChange on render', done => {
 		viewDocument.on( 'selectionChange', () => {
 			throw 'selectionChange on render';

+ 13 - 0
packages/ckeditor5-widget/docs/framework/guides/deep-dive/widget-internals.md

@@ -65,3 +65,16 @@ widgetTypeAroundPlugin.clearForceDisabled( 'MyApplication' );
 ```
 
 Refer to the {@link module:core/plugin~Plugin#clearForceDisabled API documentation} to learn more.
+
+## Exclude DOM events from default handlers
+
+Sometimes it can be useful to prevent processing of events by default handlers, for example using React component inside an `UIElement` in the widget where, by default, widget itself wants to control everything. To make it possible the only thing to do is to add a `data-cke-ignore-events` attribute to an element or to its ancestor and then all events triggered by any of children from that element will be ignored in default handlers.
+
+Let's see it in an short example:
+
+```html
+<div data-cke-ignore-events="true">
+	<button>Click!</button>
+</div>
+```
+In the above template events dispatched from the button, which is placed inside `<div>` containing `data-cke-ignore-events` attribute, will be ignored by default event handlers.

+ 132 - 0
packages/ckeditor5-widget/tests/widget-events.js

@@ -0,0 +1,132 @@
+/**
+ * @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 document, Event */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import KeyObserver from '@ckeditor/ckeditor5-engine/src/view/observer/keyobserver';
+
+import { toWidget } from '../src/utils';
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+describe( 'Widget - Events', () => {
+	const EVENT_NAME = 'keyup';
+	let editor, editorElement, eventCallback, buttonIgnored, buttonRegular;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( async () => {
+		editorElement = createEditorElement();
+		editor = await createEditor( editorElement );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		if ( editor ) {
+			return editor.destroy();
+		}
+	} );
+
+	it( 'should not ignore events from child inside parent without the `data-cke-ignore-events` attribute', () => {
+		buttonRegular.dispatchEvent( new Event( EVENT_NAME, { bubbles: true } ) );
+
+		expect( eventCallback.callCount ).to.equal( 1 );
+	} );
+
+	it( 'should ignore events from child inside parent with the `data-cke-ignore-events` attribute', () => {
+		buttonIgnored.dispatchEvent( new Event( EVENT_NAME, { bubbles: true } ) );
+
+		expect( eventCallback.callCount ).to.equal( 0 );
+	} );
+
+	function createEditorElement() {
+		return document.body.appendChild( document.createElement( 'div' ) );
+	}
+
+	async function createEditor( element ) {
+		const editor = await ClassicEditor.create( element, { plugins: [ simpleWidgetPlugin ] } );
+
+		setModelData( editor.model, '[<simpleWidgetElement></simpleWidgetElement>]' );
+
+		const container = Array
+			.from( editor.editing.view.document.getRoot().getChildren() )
+			.find( element => element.hasClass( 'simple-widget-container' ) );
+
+		const domFragment = editor.editing.view.domConverter.viewToDom( container );
+
+		buttonIgnored = domFragment.querySelector( '#ignored-button' );
+		buttonRegular = domFragment.querySelector( '#regular-button' );
+
+		return editor;
+	}
+
+	function simpleWidgetPlugin( editor ) {
+		defineSchema( editor );
+		defineConverters( editor );
+		addObserver( editor );
+
+		function defineSchema( editor ) {
+			editor.model.schema.register( 'simpleWidgetElement', {
+				inheritAllFrom: '$block',
+				isObject: true
+			} );
+		}
+
+		function defineConverters( editor ) {
+			editor.conversion.for( 'editingDowncast' )
+				.elementToElement( {
+					model: 'simpleWidgetElement',
+					view: ( modelElement, { writer } ) => {
+						const widgetElement = createWidgetView( modelElement, { writer } );
+
+						return toWidget( widgetElement, writer );
+					}
+				} );
+
+			editor.conversion.for( 'dataDowncast' )
+				.elementToElement( {
+					model: 'simpleWidgetElement',
+					view: createWidgetView
+				} );
+
+			editor.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 = `
+						<div data-cke-ignore-events="true">
+							<button id="ignored-button">Click!</button>
+						</div>
+						<div>
+							<button id="regular-button">Click!</button>
+						</div>
+					`;
+				} );
+
+				writer.insert( writer.createPositionAt( simpleWidgetContainer, 0 ), simpleWidgetElement );
+
+				return simpleWidgetContainer;
+			}
+		}
+
+		function addObserver( editor ) {
+			eventCallback = sinon.fake();
+
+			editor.editing.view.addObserver( KeyObserver );
+			editor.editing.view.document.on( EVENT_NAME, eventCallback );
+		}
+	}
+} );