Skip to content
This repository has been archived by the owner on Jun 26, 2020. It is now read-only.

Commit

Permalink
Merge pull request #12 from ckeditor/t/11
Browse files Browse the repository at this point in the history
Feature: Virtual selection support for widgets. Closes #11.
  • Loading branch information
Piotr Jasiun authored Aug 18, 2017
2 parents 2630383 + 8a19eaf commit 0bd3d66
Show file tree
Hide file tree
Showing 4 changed files with 463 additions and 3 deletions.
37 changes: 36 additions & 1 deletion src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* @module widget/utils
*/

import VirtualSelectionStack from './virtualselectionstack';

const widgetSymbol = Symbol( 'isWidget' );
const labelSymbol = Symbol( 'label' );

Expand Down Expand Up @@ -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={}]
Expand All @@ -57,9 +61,40 @@ 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
Expand Down
156 changes: 156 additions & 0 deletions src/virtualselectionstack.js
Original file line number Diff line number Diff line change
@@ -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.
*/
106 changes: 104 additions & 2 deletions tests/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
setLabel,
getLabel,
toWidgetEditable,
setVirtualSelectionHandling,
WIDGET_CLASS_NAME
} from '../src/utils';

Expand All @@ -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()', () => {
Expand Down Expand Up @@ -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 );
} );
} );
} );
Loading

0 comments on commit 0bd3d66

Please sign in to comment.