Преглед на файлове

Merge pull request #12 from ckeditor/t/11

Feature: Virtual selection support for widgets. Closes #11.
Piotr Jasiun преди 8 години
родител
ревизия
649f100167

+ 36 - 1
packages/ckeditor5-widget/src/utils.js

@@ -7,6 +7,8 @@
  * @module widget/utils
  */
 
+import VirtualSelectionStack from './virtualselectionstack';
+
 const widgetSymbol = Symbol( 'isWidget' );
 const labelSymbol = Symbol( 'label' );
 
@@ -39,7 +41,9 @@ export function isWidget( element ) {
  * * sets `contenteditable` attribute to `true`,
  * * adds custom `getFillerOffset` method returning `null`,
  * * adds `ck-widget` CSS class,
- * * adds custom property allowing to recognize widget elements by using {@link ~isWidget}.
+ * * adds custom property allowing to recognize widget elements by using {@link ~isWidget},
+ * * implements `setVirtualSelection` and `removeVirtualSelection` custom properties to handle virtual selection
+ * on widgets.
  *
  * @param {module:engine/view/element~Element} element
  * @param {Object} [options={}]
@@ -57,10 +61,41 @@ export function toWidget( element, options = {} ) {
 		setLabel( element, options.label );
 	}
 
+	setVirtualSelectionHandling(
+		element,
+		( element, descriptor ) => element.addClass( descriptor.class ),
+		( element, descriptor ) => element.removeClass( descriptor.class )
+	);
+
 	return element;
 }
 
 /**
+ * Sets virtual selection handling methods. Uses {@link module:widget/virtualselectionstack~VirtualSelectionStack} to
+ * properly determine which virtual selection should be used at given time.
+ *
+ * @param {module:engine/view/element~Element} element
+ * @param {Function} add
+ * @param {Function} remove
+ */
+export function setVirtualSelectionHandling( element, add, remove ) {
+	const stack = new VirtualSelectionStack();
+
+	stack.on( 'change:top', ( evt, data ) => {
+		if ( data.oldDescriptor ) {
+			remove( element, data.oldDescriptor );
+		}
+
+		if ( data.newDescriptor ) {
+			add( element, data.newDescriptor );
+		}
+	} );
+
+	element.setCustomProperty( 'setVirtualSelection', ( element, descriptor ) => stack.add( descriptor ) );
+	element.setCustomProperty( 'removeVirtualSelection', ( element, descriptor ) => stack.remove( descriptor ) );
+}
+
+/**
  * Sets label for given element.
  * It can be passed as a plain string or a function returning a string. Function will be called each time label is retrieved by
  * {@link ~getLabel}.

+ 156 - 0
packages/ckeditor5-widget/src/virtualselectionstack.js

@@ -0,0 +1,156 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module widget/virtualselectionstack
+ */
+
+import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
+import mix from '@ckeditor/ckeditor5-utils/src/mix';
+
+/**
+ * Class used to handle correct order of
+ * {@link module:engine/conversion/buildmodelconverter~ModelConverterBuilder#toVirtualSelection virtual selections} on
+ * elements. When different virtual selections are applied to same element correct order should be preserved:
+ * * virtual selection with highest priority should be applied,
+ * * if two virtual selections have same priority - sort by CSS class provided in
+ * {@link module:engine/conversion/buildmodelconverter~VirtualSelectionDescriptor}.
+ * This way, virtual selection will be applied with the same rules it is applied on texts.
+ */
+export default class VirtualSelectionStack {
+	/**
+	 * Creates class instance.
+	 */
+	constructor() {
+		this._stack = [];
+	}
+
+	/**
+	 * Adds virtual selection descriptor to the stack.
+	 *
+	 * @fires change:top
+	 * @param {module:engine/conversion/buildmodelconverter~VirtualSelectionDescriptor} descriptor
+	 */
+	add( descriptor ) {
+		const stack = this._stack;
+		let i = 0;
+
+		// Find correct place to insert descriptor in the stack.
+		while ( stack[ i ] && shouldABeBeforeB( stack[ i ], descriptor ) ) {
+			i++;
+		}
+
+		stack.splice( i, 0, descriptor );
+
+		// New element at the stack top.
+		if ( i === 0 ) {
+			const data = {
+				newDescriptor: descriptor
+			};
+
+			// If old descriptor is present it was pushed down the stack.
+			if ( stack[ 1 ] ) {
+				const oldDescriptor = stack[ 1 ];
+
+				// New descriptor on the top is same as previous one - do not fire any event.
+				if ( compareDescriptors( descriptor, oldDescriptor ) ) {
+					return;
+				}
+
+				data.oldDescriptor = oldDescriptor;
+			}
+
+			this.fire( 'change:top', data );
+		}
+	}
+
+	/**
+	 * Removes virtual selection descriptor from the stack.
+	 *
+	 * @fires change:top
+	 * @param {module:engine/conversion/buildmodelconverter~VirtualSelectionDescriptor} descriptor
+	 */
+	remove( descriptor ) {
+		const stack = this._stack;
+		const length = stack.length;
+
+		if ( length === 0 ) {
+			return;
+		}
+
+		let i = 0;
+
+		while ( stack[ i ] && !compareDescriptors( descriptor, stack[ i ] ) ) {
+			i++;
+
+			// Descriptor not found.
+			if ( i >= stack.length ) {
+				return;
+			}
+		}
+
+		stack.splice( i, 1 );
+
+		// Element from stack top was removed - fire `change:top` event with new first element. It might be `undefined`
+		// which informs that no selection is currently at the top.
+		if ( i === 0 ) {
+			const data = {
+				oldDescriptor: descriptor
+			};
+
+			if ( stack[ 0 ] ) {
+				const newDescriptor = stack[ 0 ];
+
+				// New descriptor on the top is same as removed one - do not fire any event.
+				if ( compareDescriptors( descriptor, newDescriptor ) ) {
+					return;
+				}
+
+				data.newDescriptor = newDescriptor;
+			}
+
+			this.fire( 'change:top', data );
+		}
+	}
+}
+
+mix( VirtualSelectionStack, EmitterMixin );
+
+// Compares two virtual selection descriptors by priority and CSS class names. Returns `true` when both descriptors are
+// considered equal.
+//
+// @param {module:engine/conversion/buildmodelconverter~VirtualSelectionDescriptor} descriptorA
+// @param {module:engine/conversion/buildmodelconverter~VirtualSelectionDescriptor} descriptorB
+// @returns {Boolean}
+function compareDescriptors( descriptorA, descriptorB ) {
+	return descriptorA.priority == descriptorB.priority && descriptorA.class == descriptorB.class;
+}
+
+// Checks whenever first descriptor should be placed in the stack before second one.
+//
+// @param {module:engine/conversion/buildmodelconverter~VirtualSelectionDescriptor} a
+// @param {module:engine/conversion/buildmodelconverter~VirtualSelectionDescriptor} b
+// @returns {Boolean}
+function shouldABeBeforeB( a, b ) {
+	if ( a.priority > b.priority ) {
+		return true;
+	} else if ( a.priority < b.priority ) {
+		return false;
+	}
+
+	// When priorities are equal and names are different - use classes to compare.
+	return a.class > b.class;
+}
+
+/**
+ * Fired when top element on {@link module:widget/virtualselectionstack~VirtualSelectionStack} has been changed
+ *
+ * @event change:top
+ * @param {Object} data Additional information about the change.
+ * @param {module:engine/conversion/buildmodelconverter~VirtualSelectionDescriptor} [data.newDescriptor] New virtual selection
+ * descriptor. It will be `undefined` when last descriptor is removed from the stack.
+ * @param {module:engine/conversion/buildmodelconverter~VirtualSelectionDescriptor} [data.oldDescriptor] Old virtual selection
+ * descriptor. It will be `undefined` when first descriptor is added to the stack.
+ */

+ 104 - 2
packages/ckeditor5-widget/tests/utils.js

@@ -12,6 +12,7 @@ import {
 	setLabel,
 	getLabel,
 	toWidgetEditable,
+	setVirtualSelectionHandling,
 	WIDGET_CLASS_NAME
 } from '../src/utils';
 
@@ -38,18 +39,32 @@ describe( 'widget utils', () => {
 		} );
 
 		it( 'should add element\'s label if one is provided', () => {
-			element = new ViewElement( 'div' );
 			toWidget( element, { label: 'foo bar baz label' } );
 
 			expect( getLabel( element ) ).to.equal( 'foo bar baz label' );
 		} );
 
 		it( 'should add element\'s label if one is provided as function', () => {
-			element = new ViewElement( 'div' );
 			toWidget( element, { label: () => 'foo bar baz label' } );
 
 			expect( getLabel( element ) ).to.equal( 'foo bar baz label' );
 		} );
+
+		it( 'should set default virtual selection methods', () => {
+			toWidget( element );
+
+			const set = element.getCustomProperty( 'setVirtualSelection' );
+			const remove = element.getCustomProperty( 'removeVirtualSelection' );
+
+			expect( typeof set ).to.equal( 'function' );
+			expect( typeof remove ).to.equal( 'function' );
+
+			set( element, { priority: 1, class: 'virtual-selection' } );
+			expect( element.hasClass( 'virtual-selection' ) ).to.be.true;
+
+			remove( element, { priority: 1, class: 'virtual-selection' } );
+			expect( element.hasClass( 'virtual-selection' ) ).to.be.false;
+		} );
 	} );
 
 	describe( 'isWidget()', () => {
@@ -121,4 +136,91 @@ describe( 'widget utils', () => {
 			expect( element.hasClass( 'ck-editable_focused' ) ).to.be.false;
 		} );
 	} );
+
+	describe( 'setVirtualSelectionHandling()', () => {
+		let element, addSpy, removeSpy, set, remove;
+
+		beforeEach( () => {
+			element = new ViewElement( 'p' );
+			addSpy = sinon.spy();
+			removeSpy = sinon.spy();
+
+			setVirtualSelectionHandling( element, addSpy, removeSpy );
+			set = element.getCustomProperty( 'setVirtualSelection' );
+			remove = element.getCustomProperty( 'removeVirtualSelection' );
+		} );
+
+		it( 'should set virtual selection methods', () => {
+			expect( typeof set ).to.equal( 'function' );
+			expect( typeof remove ).to.equal( 'function' );
+		} );
+
+		it( 'should call virtual selection methods when descriptor is added and removed', () => {
+			const descriptor = { priority: 10, class: 'virtual-selection' };
+
+			set( element, descriptor );
+			remove( element, descriptor );
+
+			sinon.assert.calledOnce( addSpy );
+			sinon.assert.calledWithExactly( addSpy, element, descriptor );
+
+			sinon.assert.calledOnce( removeSpy );
+			sinon.assert.calledWithExactly( removeSpy, element, descriptor );
+		} );
+
+		it( 'should call virtual selection methods when next descriptor is added', () => {
+			const descriptor = { priority: 10, class: 'virtual-selection' };
+			const secondDescriptor = { priority: 11, class: 'virtual-selection' };
+
+			set( element, descriptor );
+			set( element, secondDescriptor );
+
+			sinon.assert.calledTwice( addSpy );
+			expect( addSpy.firstCall.args[ 1 ] ).to.equal( descriptor );
+			expect( addSpy.secondCall.args[ 1 ] ).to.equal( secondDescriptor );
+		} );
+
+		it( 'should not call virtual selection methods when descriptor with lower priority is added', () => {
+			const descriptor = { priority: 10, class: 'virtual-selection' };
+			const secondDescriptor = { priority: 9, class: 'virtual-selection' };
+
+			set( element, descriptor );
+			set( element, secondDescriptor );
+
+			sinon.assert.calledOnce( addSpy );
+			expect( addSpy.firstCall.args[ 1 ] ).to.equal( descriptor );
+		} );
+
+		it( 'should call virtual selection methods when descriptor is removed changing active descriptor', () => {
+			const descriptor = { priority: 10, class: 'virtual-selection' };
+			const secondDescriptor = { priority: 11, class: 'virtual-selection' };
+
+			set( element, descriptor );
+			set( element, secondDescriptor );
+			remove( element, secondDescriptor );
+
+			sinon.assert.calledThrice( addSpy );
+			expect( addSpy.firstCall.args[ 1 ] ).to.equal( descriptor );
+			expect( addSpy.secondCall.args[ 1 ] ).to.equal( secondDescriptor );
+			expect( addSpy.thirdCall.args[ 1 ] ).to.equal( descriptor );
+
+			sinon.assert.calledTwice( removeSpy );
+			expect( removeSpy.firstCall.args[ 1 ] ).to.equal( descriptor );
+			expect( removeSpy.secondCall.args[ 1 ] ).to.equal( secondDescriptor );
+		} );
+
+		it( 'should call virtual selection methods when descriptor is removed not changing active descriptor', () => {
+			const descriptor = { priority: 10, class: 'virtual-selection' };
+			const secondDescriptor = { priority: 9, class: 'virtual-selection' };
+
+			set( element, descriptor );
+			set( element, secondDescriptor );
+			remove( element, secondDescriptor );
+
+			sinon.assert.calledOnce( addSpy );
+			expect( addSpy.firstCall.args[ 1 ] ).to.equal( descriptor );
+
+			sinon.assert.notCalled( removeSpy );
+		} );
+	} );
 } );

+ 167 - 0
packages/ckeditor5-widget/tests/virtualselectionstack.js

@@ -0,0 +1,167 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import VirtualSelectionStack from '../src/virtualselectionstack';
+
+describe( 'VirtualSelectionStack', () => {
+	let stack;
+
+	beforeEach( () => {
+		stack = new VirtualSelectionStack();
+	} );
+
+	it( 'should fire event when new descriptor is provided to an empty stack', () => {
+		const spy = sinon.spy();
+		const descriptor = { priority: 10, class: 'css-class' };
+
+		stack.on( 'change:top', spy );
+		stack.add( descriptor );
+
+		sinon.assert.calledOnce( spy );
+		expect( spy.firstCall.args[ 1 ].newDescriptor ).to.equal( descriptor );
+		expect( spy.firstCall.args[ 1 ].oldDescriptor ).to.be.undefined;
+	} );
+
+	it( 'should fire event when new top element has changed', () => {
+		const spy = sinon.spy();
+		const descriptor = { priority: 10, class: 'css-class' };
+		const secondDescriptor = { priority: 11, class: 'css-class' };
+
+		stack.on( 'change:top', spy );
+		stack.add( descriptor );
+		stack.add( secondDescriptor );
+
+		sinon.assert.calledTwice( spy );
+		expect( spy.secondCall.args[ 1 ].newDescriptor ).to.equal( secondDescriptor );
+		expect( spy.secondCall.args[ 1 ].oldDescriptor ).to.equal( descriptor );
+	} );
+
+	it( 'should not fire event when element with lower priority was added', () => {
+		const spy = sinon.spy();
+		const descriptor = { priority: 10, class: 'css-class' };
+		const secondDescriptor = { priority: 9, class: 'css-class' };
+
+		stack.on( 'change:top', spy );
+		stack.add( descriptor );
+		stack.add( secondDescriptor );
+
+		sinon.assert.calledOnce( spy );
+		expect( spy.firstCall.args[ 1 ].newDescriptor ).to.equal( descriptor );
+		expect( spy.firstCall.args[ 1 ].oldDescriptor ).to.be.undefined;
+	} );
+
+	it( 'should fire event when top element was removed', () => {
+		const spy = sinon.spy();
+		const descriptor = { priority: 10, class: 'css-class' };
+		const secondDescriptor = { priority: 11, class: 'css-class' };
+
+		stack.add( descriptor );
+		stack.add( secondDescriptor );
+
+		stack.on( 'change:top', spy );
+
+		stack.remove( secondDescriptor );
+
+		sinon.assert.calledOnce( spy );
+		expect( spy.firstCall.args[ 1 ].oldDescriptor ).to.equal( secondDescriptor );
+		expect( spy.firstCall.args[ 1 ].newDescriptor ).to.equal( descriptor );
+	} );
+
+	it( 'should not fire event when other than top element is removed', () => {
+		const spy = sinon.spy();
+		const descriptor = { priority: 10, class: 'css-class' };
+		const secondDescriptor = { priority: 11, class: 'css-class' };
+
+		stack.add( descriptor );
+		stack.add( secondDescriptor );
+
+		stack.on( 'change:top', spy );
+
+		stack.remove( descriptor );
+
+		sinon.assert.notCalled( spy );
+	} );
+
+	it( 'should not fire event when same descriptor is added', () => {
+		const spy = sinon.spy();
+		const descriptor = { priority: 10, class: 'css-class' };
+		const secondDescriptor = { priority: 10, class: 'css-class' };
+
+		stack.on( 'change:top', spy );
+		stack.add( descriptor );
+		stack.add( secondDescriptor );
+
+		sinon.assert.calledOnce( spy );
+		expect( spy.firstCall.args[ 1 ].newDescriptor ).to.equal( descriptor );
+		expect( spy.firstCall.args[ 1 ].oldDescriptor ).to.be.undefined;
+	} );
+
+	it( 'should not fire when trying to remove from empty stack', () => {
+		const spy = sinon.spy();
+		const descriptor = { priority: 10, class: 'css-class' };
+
+		stack.on( 'change:top', spy );
+		stack.remove( descriptor );
+
+		sinon.assert.notCalled( spy );
+	} );
+
+	it( 'should not fire when trying to remove descriptor which is not present', () => {
+		const spy = sinon.spy();
+		const descriptor = { priority: 10, class: 'css-class' };
+		const secondDescriptor = { priority: 12, class: 'css-class' };
+
+		stack.add( descriptor );
+		stack.on( 'change:top', spy );
+		stack.remove( secondDescriptor );
+
+		sinon.assert.notCalled( spy );
+	} );
+
+	it( 'should fire event when last element from stack was removed', () => {
+		const spy = sinon.spy();
+		const descriptor = { priority: 10, class: 'css-class' };
+
+		stack.add( descriptor );
+		stack.on( 'change:top', spy );
+		stack.remove( descriptor );
+
+		sinon.assert.calledOnce( spy );
+		expect( spy.firstCall.args[ 1 ].newDescriptor ).to.be.undefined;
+		expect( spy.firstCall.args[ 1 ].oldDescriptor ).to.equal( descriptor );
+	} );
+
+	it( 'should not fire event when new top descriptor is same as previous', () => {
+		const spy = sinon.spy();
+		const descriptor = { priority: 10, class: 'css-class' };
+		const secondDescriptor = { priority: 10, class: 'css-class' };
+
+		stack.add( descriptor );
+		stack.add( secondDescriptor );
+		stack.on( 'change:top', spy );
+		stack.remove( secondDescriptor );
+
+		sinon.assert.notCalled( spy );
+	} );
+
+	it( 'should sort by class when priorities are the same', () => {
+		const spy = sinon.spy();
+		const descriptorA = { priority: 10, class: 'css-a' };
+		const descriptorB = { priority: 10, class: 'css-b' };
+		const descriptorC = { priority: 10, class: 'css-c' };
+
+		stack.on( 'change:top', spy );
+		stack.add( descriptorB );
+		stack.add( descriptorA );
+		stack.add( descriptorC );
+
+		sinon.assert.calledTwice( spy );
+		expect( spy.firstCall.args[ 1 ].newDescriptor ).to.equal( descriptorB );
+		expect( spy.firstCall.args[ 1 ].oldDescriptor ).to.be.undefined;
+
+		expect( spy.secondCall.args[ 1 ].newDescriptor ).to.equal( descriptorC );
+		expect( spy.secondCall.args[ 1 ].oldDescriptor ).to.equal( descriptorB );
+	} );
+} );