From 3ae5ec0eae76cccd8d1a54ada15fcb3f2b4252b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Szymon=20Kup=C5=9B?= Date: Fri, 31 Mar 2017 13:50:08 +0200 Subject: [PATCH 1/3] Moved files from image feature. --- package.json | 4 + src/utils.js | 121 ++++++++ src/widget.js | 262 ++++++++++++++++ src/widgetengine.js | 50 +++ tests/.jshintrc | 22 ++ tests/utils.js | 116 +++++++ tests/widget.js | 708 ++++++++++++++++++++++++++++++++++++++++++ tests/widgetengine.js | 97 ++++++ theme/theme.scss | 41 +++ 9 files changed, 1421 insertions(+) create mode 100644 src/utils.js create mode 100644 src/widget.js create mode 100644 src/widgetengine.js create mode 100644 tests/.jshintrc create mode 100644 tests/utils.js create mode 100644 tests/widget.js create mode 100644 tests/widgetengine.js create mode 100644 theme/theme.scss diff --git a/package.json b/package.json index cc4b8010..a941f9a4 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,10 @@ "description": "Widget feature for CKEditor 5.", "keywords": [], "dependencies": { + "@ckeditor/ckeditor5-core": "^0.7.0", + "@ckeditor/ckeditor5-engine": "^0.8.0", + "@ckeditor/ckeditor5-utils": "^0.8.0", + "@ckeditor/ckeditor5-theme-lark": "^0.6.1" }, "devDependencies": { "@ckeditor/ckeditor5-dev-lint": "^2.0.2", diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 00000000..3c24c952 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,121 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module widget/utils + */ + +const widgetSymbol = Symbol( 'isWidget' ); +const labelSymbol = Symbol( 'label' ); + +/** + * CSS class added to each widget element. + * + * @const {String} + */ +export const WIDGET_CLASS_NAME = 'ck-widget'; + +/** + * CSS class added to currently selected widget element. + * + * @const {String} + */ +export const WIDGET_SELECTED_CLASS_NAME = 'ck-widget_selected'; + +/** + * Returns `true` if given {@link module:engine/view/element~Element} is a widget. + * + * @param {module:engine/view/element~Element} element + * @returns {Boolean} + */ +export function isWidget( element ) { + return !!element.getCustomProperty( widgetSymbol ); +} + +/** + * Converts given {@link module:engine/view/element~Element} to widget in following way: + * * 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}. + * + * @param {module:engine/view/element~Element} element + * @param {Object} [options] + * @param {String|Function} [options.label] Element's label provided to {@link ~setLabel} function. It can be passed as + * a plain string or a function returning a string. + * @returns {module:engine/view/element~Element} Returns same element. + */ +export function toWidget( element, options ) { + options = options || {}; + element.setAttribute( 'contenteditable', false ); + element.getFillerOffset = getFillerOffset; + element.addClass( WIDGET_CLASS_NAME ); + element.setCustomProperty( widgetSymbol, true ); + + if ( options.label ) { + setLabel( element, options.label ); + } + + return element; +} + +/** + * 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}. + * + * @param {module:engine/view/element~Element} element + * @param {String|Function} labelOrCreator + */ +export function setLabel( element, labelOrCreator ) { + element.setCustomProperty( labelSymbol, labelOrCreator ); +} + +/** + * Returns label for provided element. + * + * @param {module:engine/view/element~Element} element + * @return {String} + */ +export function getLabel( element ) { + const labelCreator = element.getCustomProperty( labelSymbol ); + + if ( !labelCreator ) { + return ''; + } + + return typeof labelCreator == 'function' ? labelCreator() : labelCreator; +} + +/** + * Adds functionality to provided {module:engine/view/editableelement~EditableElement} to act as a widget's editable: + * * sets `contenteditable` attribute to `true`, + * * adds `ck-editable` CSS class, + * * adds `ck-editable_focused` CSS class when editable is focused and removes it when it's blurred. + * + * @param {module:engine/view/editableelement~EditableElement} editable + * @returns {module:engine/view/editableelement~EditableElement} Returns same element that was provided in `editable` param. + */ +export function toWidgetEditable( editable ) { + editable.setAttribute( 'contenteditable', 'true' ); + editable.addClass( 'ck-editable' ); + + editable.on( 'change:isFocused', ( evt, property, is ) => { + if ( is ) { + editable.addClass( 'ck-editable_focused' ); + } else { + editable.removeClass( 'ck-editable_focused' ); + } + } ); + + return editable; +} + +// Default filler offset function applied to all widget elements. +// +// @returns {null} +function getFillerOffset() { + return null; +} diff --git a/src/widget.js b/src/widget.js new file mode 100644 index 00000000..b28e1c55 --- /dev/null +++ b/src/widget.js @@ -0,0 +1,262 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module widget/widget + */ + +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import WidgetEngine from './widgetengine'; +import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver'; +import ModelRange from '@ckeditor/ckeditor5-engine/src/model/range'; +import ModelSelection from '@ckeditor/ckeditor5-engine/src/model/selection'; +import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element'; +import ViewEditableElement from '@ckeditor/ckeditor5-engine/src/view/editableelement'; +import RootEditableElement from '@ckeditor/ckeditor5-engine/src/view/rooteditableelement'; +import { isWidget } from './utils'; +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; + +import '../theme/theme.scss'; + +/** + * The widget plugin. + * Adds default {@link module:engine/view/document~Document#event:mousedown mousedown} handling on widget elements. + * + * @extends module:core/plugin~Plugin. + */ +export default class Widget extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ WidgetEngine ]; + } + + /** + * @inheritDoc + */ + init() { + const viewDocument = this.editor.editing.view; + + // If mouse down is pressed on widget - create selection over whole widget. + viewDocument.addObserver( MouseObserver ); + this.listenTo( viewDocument, 'mousedown', ( ...args ) => this._onMousedown( ...args ) ); + + // Handle custom keydown behaviour. + this.listenTo( viewDocument, 'keydown', ( ...args ) => this._onKeydown( ...args ), { priority: 'high' } ); + } + + /** + * Handles {@link module:engine/view/document~Document#event:mousedown mousedown} events on widget elements. + * + * @private + * @param {module:utils/eventinfo~EventInfo} eventInfo + * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData + */ + _onMousedown( eventInfo, domEventData ) { + const editor = this.editor; + const viewDocument = editor.editing.view; + let element = domEventData.target; + + // Do nothing if inside nested editable. + if ( isInsideNestedEditable( element ) ) { + return; + } + + // If target is not a widget element - check if one of the ancestors is. + if ( !isWidget( element ) ) { + element = element.findAncestor( isWidget ); + + if ( !element ) { + return; + } + } + + domEventData.preventDefault(); + + // Focus editor if is not focused already. + if ( !viewDocument.isFocused ) { + viewDocument.focus(); + } + + // Create model selection over widget. + const modelElement = editor.editing.mapper.toModelElement( element ); + + editor.document.enqueueChanges( ( ) => { + this._setSelectionOverElement( modelElement ); + } ); + } + + /** + * Handles {@link module:engine/view/document~Document#event:keydown keydown} events. + * + * @private + * @param {module:utils/eventinfo~EventInfo} eventInfo + * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData + */ + _onKeydown( eventInfo, domEventData ) { + const keyCode = domEventData.keyCode; + const isForward = keyCode == keyCodes.delete || keyCode == keyCodes.arrowdown || keyCode == keyCodes.arrowright; + + // Checks if delete/backspace or arrow keys were handled and then prevents default event behaviour and stops + // event propagation. + if ( ( isDeleteKeyCode( keyCode ) && this._handleDelete( isForward ) ) || + ( isArrowKeyCode( keyCode ) && this._handleArrowKeys( isForward ) ) ) { + domEventData.preventDefault(); + eventInfo.stop(); + } + } + + /** + * Handles delete keys: backspace and delete. + * + * @private + * @param {Boolean} isForward Set to true if delete was performed in forward direction. + * @returns {Boolean|undefined} Returns `true` if keys were handled correctly. + */ + _handleDelete( isForward ) { + const modelDocument = this.editor.document; + const modelSelection = modelDocument.selection; + + // Do nothing on non-collapsed selection. + if ( !modelSelection.isCollapsed ) { + return; + } + + const objectElement = this._getObjectElementNextToSelection( isForward ); + + if ( objectElement ) { + modelDocument.enqueueChanges( () => { + // Remove previous element if empty. + const previousNode = modelSelection.anchor.parent; + + if ( previousNode.isEmpty ) { + const batch = modelDocument.batch(); + batch.remove( previousNode ); + } + + this._setSelectionOverElement( objectElement ); + } ); + + return true; + } + } + + /** + * Handles arrow keys. + * + * @param {Boolean} isForward Set to true if arrow key should be handled in forward direction. + * @returns {Boolean|undefined} Returns `true` if keys were handled correctly. + */ + _handleArrowKeys( isForward ) { + const modelDocument = this.editor.document; + const schema = modelDocument.schema; + const modelSelection = modelDocument.selection; + const objectElement = modelSelection.getSelectedElement(); + + // if object element is selected. + if ( objectElement && schema.objects.has( objectElement.name ) ) { + const position = isForward ? modelSelection.getLastPosition() : modelSelection.getFirstPosition(); + const newRange = modelDocument.getNearestSelectionRange( position, isForward ? 'forward' : 'backward' ); + + if ( newRange ) { + modelDocument.enqueueChanges( () => { + modelSelection.setRanges( [ newRange ] ); + } ); + } + + return true; + } + + // If selection is next to object element. + // Return if not collapsed. + if ( !modelSelection.isCollapsed ) { + return; + } + + const objectElement2 = this._getObjectElementNextToSelection( isForward ); + + if ( objectElement2 instanceof ModelElement && modelDocument.schema.objects.has( objectElement2.name ) ) { + modelDocument.enqueueChanges( () => { + this._setSelectionOverElement( objectElement2 ); + } ); + + return true; + } + } + + /** + * Sets {@link module:engine/model/selection~Selection document's selection} over given element. + * + * @private + * @param {module:engine/model/element~Element} element + */ + _setSelectionOverElement( element ) { + this.editor.document.selection.setRanges( [ ModelRange.createOn( element ) ] ); + } + + /** + * Checks if {@link module:engine/model/element~Element element} placed next to the current + * {@link module:engine/model/selection~Selection model selection} exists and is marked in + * {@link module:engine/model/schema~Schema schema} as `object`. + * + * @private + * @param {Boolean} forward Direction of checking. + * @returns {module:engine/model/element~Element|null} + */ + _getObjectElementNextToSelection( forward ) { + const modelDocument = this.editor.document; + const schema = modelDocument.schema; + const modelSelection = modelDocument.selection; + const dataController = this.editor.data; + + // Clone current selection to use it as a probe. We must leave default selection as it is so it can return + // to its current state after undo. + const probe = ModelSelection.createFromSelection( modelSelection ); + dataController.modifySelection( probe, { direction: forward ? 'forward' : 'backward' } ); + const objectElement = forward ? probe.focus.nodeBefore : probe.focus.nodeAfter; + + if ( objectElement instanceof ModelElement && schema.objects.has( objectElement.name ) ) { + return objectElement; + } + + return null; + } +} + +// Returns 'true' if provided key code represents one of the arrow keys. +// +// @param {Number} keyCode +// @returns {Boolean} +function isArrowKeyCode( keyCode ) { + return keyCode == keyCodes.arrowright || + keyCode == keyCodes.arrowleft || + keyCode == keyCodes.arrowup || + keyCode == keyCodes.arrowdown; +} + +//Returns 'true' if provided key code represents one of the delete keys: delete or backspace. +// +//@param {Number} keyCode +//@returns {Boolean} +function isDeleteKeyCode( keyCode ) { + return keyCode == keyCodes.delete || keyCode == keyCodes.backspace; +} + +// Returns `true` when element is a nested editable or is placed inside one. +// +// @param {module:engine/view/element~Element} +// @returns {Boolean} +function isInsideNestedEditable( element ) { + while ( element ) { + if ( element instanceof ViewEditableElement && !( element instanceof RootEditableElement ) ) { + return true; + } + + element = element.parent; + } + + return false; +} diff --git a/src/widgetengine.js b/src/widgetengine.js new file mode 100644 index 00000000..611751bc --- /dev/null +++ b/src/widgetengine.js @@ -0,0 +1,50 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module widget/widgetengine + */ + +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import { WIDGET_SELECTED_CLASS_NAME, isWidget, getLabel } from './utils'; + +/** + * The widget engine plugin. + * Registers model to view selection converter for editing pipeline. It is hooked after default selection conversion. + * If converted selection is placed around widget element, selection is marked as fake. Additionally, proper CSS class + * is added to indicate that widget has been selected. + * + * @extends module:core/plugin~Plugin. + */ +export default class WidgetEngine extends Plugin { + /** + * @inheritDoc + */ + init() { + let previouslySelected; + + // Model to view selection converter. + // Converts selection placed over widget element to fake selection + this.editor.editing.modelToView.on( 'selection', ( evt, data, consumable, conversionApi ) => { + // Remove selected class from previously selected widget. + if ( previouslySelected && previouslySelected.hasClass( WIDGET_SELECTED_CLASS_NAME ) ) { + previouslySelected.removeClass( WIDGET_SELECTED_CLASS_NAME ); + } + + const viewSelection = conversionApi.viewSelection; + + // Check if widget was clicked or some sub-element. + const selectedElement = viewSelection.getSelectedElement(); + + if ( !selectedElement || !isWidget( selectedElement ) ) { + return; + } + + viewSelection.setFake( true, { label: getLabel( selectedElement ) } ); + selectedElement.addClass( WIDGET_SELECTED_CLASS_NAME ); + previouslySelected = selectedElement; + }, { priority: 'low' } ); + } +} diff --git a/tests/.jshintrc b/tests/.jshintrc new file mode 100644 index 00000000..912ffcd3 --- /dev/null +++ b/tests/.jshintrc @@ -0,0 +1,22 @@ +{ + "esnext": true, + "expr": true, + "immed": true, + "loopfunc": true, + "noarg": true, + "nonbsp": true, + "strict": "implied", + "undef": true, + "unused": true, + "varstmt": true, + "globals": { + "after": false, + "afterEach": false, + "before": false, + "beforeEach": false, + "describe": false, + "expect": false, + "it": false, + "sinon": false + } +} diff --git a/tests/utils.js b/tests/utils.js new file mode 100644 index 00000000..128c5949 --- /dev/null +++ b/tests/utils.js @@ -0,0 +1,116 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import ViewElement from '@ckeditor/ckeditor5-engine/src/view/element'; +import ViewEditableElement from '@ckeditor/ckeditor5-engine/src/view/editableelement'; +import ViewDocument from '@ckeditor/ckeditor5-engine/src/view/document'; +import { + toWidget, + isWidget, + setLabel, + getLabel, + toWidgetEditable, + WIDGET_CLASS_NAME +} from '../src/utils'; + +describe( 'widget utils', () => { + let element; + + beforeEach( () => { + element = new ViewElement( 'div' ); + toWidget( element ); + } ); + + describe( 'toWidget()', () => { + it( 'should set contenteditable to false', () => { + expect( element.getAttribute( 'contenteditable' ) ).to.be.false; + } ); + + it( 'should define getFillerOffset method', () => { + expect( element.getFillerOffset ).to.be.function; + expect( element.getFillerOffset() ).to.be.null; + } ); + + it( 'should add proper CSS class', () => { + expect( element.hasClass( WIDGET_CLASS_NAME ) ).to.be.true; + } ); + + 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' ); + } ); + } ); + + describe( 'isWidget()', () => { + it( 'should return true for widgetized elements', () => { + expect( isWidget( element ) ).to.be.true; + } ); + + it( 'should return false for non-widgetized elements', () => { + expect( isWidget( new ViewElement( 'p' ) ) ).to.be.false; + } ); + } ); + + describe( 'label utils', () => { + it( 'should allow to set label for element', () => { + const element = new ViewElement( 'p' ); + setLabel( element, 'foo bar baz' ); + + expect( getLabel( element ) ).to.equal( 'foo bar baz' ); + } ); + + it( 'should return empty string for elements without label', () => { + const element = new ViewElement( 'div' ); + + expect( getLabel( element ) ).to.equal( '' ); + } ); + + it( 'should allow to use a function as label creator', () => { + const element = new ViewElement( 'p' ); + let caption = 'foo'; + setLabel( element, () => caption ); + + expect( getLabel( element ) ).to.equal( 'foo' ); + caption = 'bar'; + expect( getLabel( element ) ).to.equal( 'bar' ); + } ); + } ); + + describe( 'toWidgetEditable', () => { + let viewDocument, element; + + beforeEach( () => { + viewDocument = new ViewDocument(); + element = new ViewEditableElement( 'div' ); + element.document = viewDocument; + toWidgetEditable( element ); + } ); + + it( 'should be created in context of proper document', () => { + expect( element.document ).to.equal( viewDocument ); + } ); + + it( 'should add proper class', () => { + expect( element.hasClass( 'ck-editable' ) ).to.be.true; + } ); + + it( 'should add proper class when element is focused', () => { + element.isFocused = true; + expect( element.hasClass( 'ck-editable_focused' ) ).to.be.true; + + element.isFocused = false; + expect( element.hasClass( 'ck-editable_focused' ) ).to.be.false; + } ); + } ); +} ); diff --git a/tests/widget.js b/tests/widget.js new file mode 100644 index 00000000..7efe6c60 --- /dev/null +++ b/tests/widget.js @@ -0,0 +1,708 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import Widget from '../src/widget'; +import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver'; +import buildModelConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildmodelconverter'; +import { toWidget } from '../src/utils'; +import ViewContainer from '@ckeditor/ckeditor5-engine/src/view/containerelement'; +import ViewEditable from '@ckeditor/ckeditor5-engine/src/view/editableelement'; +import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; +import AttributeContainer from '@ckeditor/ckeditor5-engine/src/view/attributeelement'; +import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; + +/* global document */ + +describe( 'Widget', () => { + let editor, doc, viewDocument; + + beforeEach( () => { + return VirtualTestEditor.create( { + plugins: [ Widget ] + } ) + .then( newEditor => { + editor = newEditor; + doc = editor.document; + viewDocument = editor.editing.view; + + doc.schema.registerItem( 'widget', '$block' ); + doc.schema.objects.add( 'widget' ); + doc.schema.registerItem( 'paragraph', '$block' ); + doc.schema.registerItem( 'inline', '$inline' ); + doc.schema.objects.add( 'inline' ); + doc.schema.registerItem( 'nested' ); + doc.schema.allow( { name: '$inline', inside: 'nested' } ); + doc.schema.allow( { name: 'nested', inside: 'widget' } ); + + buildModelConverter().for( editor.editing.modelToView ) + .fromElement( 'paragraph' ) + .toElement( 'p' ); + + buildModelConverter().for( editor.editing.modelToView ) + .fromElement( 'widget' ) + .toElement( () => { + const b = new AttributeContainer( 'b' ); + const div = new ViewContainer( 'div', null, b ); + + return toWidget( div ); + } ); + + buildModelConverter().for( editor.editing.modelToView ) + .fromElement( 'inline' ) + .toElement( 'figure' ); + + buildModelConverter().for( editor.editing.modelToView ) + .fromElement( 'nested' ) + .toElement( () => new ViewEditable( 'figcaption', { contenteditable: true } ) ); + } ); + } ); + + it( 'should be loaded', () => { + expect( editor.plugins.get( Widget ) ).to.be.instanceOf( Widget ); + } ); + + it( 'should add MouseObserver', () => { + expect( editor.editing.view.getObserver( MouseObserver ) ).to.be.instanceof( MouseObserver ); + } ); + + it( 'should create selection over clicked widget', () => { + setModelData( doc, '[]' ); + const viewDiv = viewDocument.getRoot().getChild( 0 ); + const domEventDataMock = { + target: viewDiv, + preventDefault: sinon.spy() + }; + + viewDocument.fire( 'mousedown', domEventDataMock ); + + expect( getModelData( doc ) ).to.equal( '[]' ); + sinon.assert.calledOnce( domEventDataMock.preventDefault ); + } ); + + it( 'should create selection when clicked in nested element', () => { + setModelData( doc, '[]' ); + const viewDiv = viewDocument.getRoot().getChild( 0 ); + const viewB = viewDiv.getChild( 0 ); + const domEventDataMock = { + target: viewB, + preventDefault: sinon.spy() + }; + + viewDocument.fire( 'mousedown', domEventDataMock ); + + expect( getModelData( doc ) ).to.equal( '[]' ); + sinon.assert.calledOnce( domEventDataMock.preventDefault ); + } ); + + it( 'should do nothing if clicked inside nested editable', () => { + setModelData( doc, '[]foo bar' ); + const viewDiv = viewDocument.getRoot().getChild( 0 ); + const viewFigcaption = viewDiv.getChild( 0 ); + + const domEventDataMock = { + target: viewFigcaption, + preventDefault: sinon.spy() + }; + + viewDocument.fire( 'mousedown', domEventDataMock ); + + sinon.assert.notCalled( domEventDataMock.preventDefault ); + } ); + + it( 'should do nothing if clicked in non-widget element', () => { + setModelData( doc, '[]foo bar' ); + const viewP = viewDocument.getRoot().getChild( 0 ); + const domEventDataMock = { + target: viewP, + preventDefault: sinon.spy() + }; + + viewDocument.focus(); + viewDocument.fire( 'mousedown', domEventDataMock ); + + expect( getModelData( doc ) ).to.equal( '[]foo bar' ); + sinon.assert.notCalled( domEventDataMock.preventDefault ); + } ); + + it( 'should not focus editable if already is focused', () => { + setModelData( doc, '' ); + const widget = viewDocument.getRoot().getChild( 0 ); + const domEventDataMock = { + target: widget, + preventDefault: sinon.spy() + }; + const focusSpy = sinon.spy( viewDocument, 'focus' ); + + viewDocument.isFocused = true; + viewDocument.fire( 'mousedown', domEventDataMock ); + + sinon.assert.calledOnce( domEventDataMock.preventDefault ); + sinon.assert.notCalled( focusSpy ); + expect( getModelData( doc ) ).to.equal( '[]' ); + } ); + + describe( 'keys handling', () => { + describe( 'delete and backspace', () => { + test( + 'should select widget when backspace is pressed', + '[]foo', + keyCodes.backspace, + '[]foo' + ); + + test( + 'should remove empty element after selecting widget when backspace is pressed', + '[]', + keyCodes.backspace, + '[]' + ); + + test( + 'should select widget when delete is pressed', + 'foo[]', + keyCodes.delete, + 'foo[]' + ); + + test( + 'should remove empty element after selecting widget when delete is pressed', + '[]', + keyCodes.delete, + '[]' + ); + + test( + 'should not respond to other keys', + '[]foo', + 65, + '[]foo' + ); + + test( + 'should do nothing on non-collapsed selection', + '[f]oo', + keyCodes.backspace, + '[f]oo' + ); + + test( + 'should do nothing on non-object elements', + 'foo[]bar', + keyCodes.backspace, + 'foo[]bar' + ); + + test( + 'should work correctly with modifier key: backspace + ctrl', + '[]foo', + { keyCode: keyCodes.backspace, ctrlKey: true }, + '[]foo' + ); + + test( + 'should work correctly with modifier key: backspace + alt', + '[]foo', + { keyCode: keyCodes.backspace, altKey: true }, + '[]foo' + ); + + test( + 'should work correctly with modifier key: backspace + shift', + '[]foo', + { keyCode: keyCodes.backspace, shiftKey: true }, + '[]foo' + ); + + test( + 'should work correctly with modifier key: delete + ctrl', + 'foo[]', + { keyCode: keyCodes.delete, ctrlKey: true }, + 'foo[]' + ); + + test( + 'should work correctly with modifier key: delete + alt', + 'foo[]', + { keyCode: keyCodes.delete, altKey: true }, + 'foo[]' + ); + + test( + 'should work correctly with modifier key: delete + shift', + 'foo[]', + { keyCode: keyCodes.delete, shiftKey: true }, + 'foo[]' + ); + + test( + 'should not modify backspace default behaviour in single paragraph boundaries', + '[]foo', + keyCodes.backspace, + '[]foo' + ); + + test( + 'should not modify delete default behaviour in single paragraph boundaries', + 'foo[]', + keyCodes.delete, + 'foo[]' + ); + + test( + 'should do nothing on selected widget preceded by a paragraph - backspace', + 'foo[]', + keyCodes.backspace, + 'foo[]' + ); + + test( + 'should do nothing on selected widget preceded by another widget - backspace', + '[]', + keyCodes.backspace, + '[]' + ); + + test( + 'should do nothing on selected widget before paragraph - backspace', + '[]foo', + keyCodes.backspace, + '[]foo' + ); + + test( + 'should do nothing on selected widget before another widget - backspace', + '[]', + keyCodes.backspace, + '[]' + ); + + test( + 'should do nothing on selected widget between paragraphs - backspace', + 'bar[]foo', + keyCodes.backspace, + 'bar[]foo' + ); + + test( + 'should do nothing on selected widget between other widgets - backspace', + '[]', + keyCodes.backspace, + '[]' + ); + + test( + 'should do nothing on selected widget preceded by a paragraph - delete', + 'foo[]', + keyCodes.delete, + 'foo[]' + ); + + test( + 'should do nothing on selected widget preceded by another widget - delete', + '[]', + keyCodes.delete, + '[]' + ); + + test( + 'should do nothing on selected widget before paragraph - delete', + '[]foo', + keyCodes.delete, + '[]foo' + ); + + test( + 'should do nothing on selected widget before another widget - delete', + '[]', + keyCodes.delete, + '[]' + ); + + test( + 'should do nothing on selected widget between paragraphs - delete', + 'bar[]foo', + keyCodes.delete, + 'bar[]foo' + ); + + test( + 'should do nothing on selected widget between other widgets - delete', + '[]', + keyCodes.delete, + '[]' + ); + + test( + 'should select inline objects - backspace', + 'foo[]bar', + keyCodes.backspace, + 'foo[]bar' + ); + + test( + 'should select inline objects - delete', + 'foo[]bar', + keyCodes.delete, + 'foo[]bar' + ); + + test( + 'should do nothing on selected inline objects - backspace', + 'foo[]bar', + keyCodes.backspace, + 'foo[]bar' + ); + + test( + 'should do nothing on selected inline objects - delete', + 'foo[]bar', + keyCodes.delete, + 'foo[]bar' + ); + + test( + 'should do nothing if selection is placed after first letter - backspace', + 'a[]', + keyCodes.backspace, + 'a[]' + ); + + test( + 'should do nothing if selection is placed before first letter - delete', + '[]a', + keyCodes.delete, + '[]a' + ); + + it( 'should prevent default behaviour and stop event propagation', () => { + const keydownHandler = sinon.spy(); + const domEventDataMock = { + keyCode: keyCodes.delete, + preventDefault: sinon.spy(), + }; + setModelData( doc, 'foo[]' ); + viewDocument.on( 'keydown', keydownHandler ); + + viewDocument.fire( 'keydown', domEventDataMock ); + + expect( getModelData( doc ) ).to.equal( 'foo[]' ); + sinon.assert.calledOnce( domEventDataMock.preventDefault ); + sinon.assert.notCalled( keydownHandler ); + } ); + } ); + + describe( 'arrows', () => { + test( + 'should move selection forward from selected object - right arrow', + '[]foo', + keyCodes.arrowright, + '[]foo' + ); + + test( + 'should move selection forward from selected object - down arrow', + '[]foo', + keyCodes.arrowdown, + '[]foo' + ); + + test( + 'should move selection backward from selected object - left arrow', + 'foo[]', + keyCodes.arrowleft, + 'foo[]' + ); + + test( + 'should move selection backward from selected object - up arrow', + 'foo[]', + keyCodes.arrowup, + 'foo[]' + ); + + test( + 'should move selection to next widget - right arrow', + '[]', + keyCodes.arrowright, + '[]' + ); + + test( + 'should move selection to next widget - down arrow', + '[]', + keyCodes.arrowdown, + '[]' + ); + + test( + 'should move selection to previous widget - left arrow', + '[]', + keyCodes.arrowleft, + '[]' + ); + + test( + 'should move selection to previous widget - up arrow', + '[]', + keyCodes.arrowup, + '[]' + ); + + test( + 'should do nothing on non-collapsed selection next to object - right arrow', + 'ba[r]', + keyCodes.arrowright, + 'ba[r]' + ); + + test( + 'should do nothing on non-collapsed selection next to object - down arrow', + 'ba[r]', + keyCodes.arrowdown, + 'ba[r]' + ); + + test( + 'should do nothing on non-collapsed selection next to object - left arrow', + '[b]ar', + keyCodes.arrowleft, + '[b]ar' + ); + + test( + 'should do nothing on non-collapsed selection next to object - up arrow', + '[b]ar', + keyCodes.arrowup, + '[b]ar' + ); + + test( + 'should not move selection if there is no correct location - right arrow', + 'foo[]', + keyCodes.arrowright, + 'foo[]' + ); + + test( + 'should not move selection if there is no correct location - down arrow', + 'foo[]', + keyCodes.arrowdown, + 'foo[]' + ); + + test( + 'should not move selection if there is no correct location - left arrow', + '[]foo', + keyCodes.arrowleft, + '[]foo' + ); + + test( + 'should not move selection if there is no correct location - up arrow', + '[]foo', + keyCodes.arrowup, + '[]foo' + ); + + it( 'should prevent default behaviour when there is no correct location - document end', () => { + const keydownHandler = sinon.spy(); + const domEventDataMock = { + keyCode: keyCodes.arrowright, + preventDefault: sinon.spy(), + }; + setModelData( doc, 'foo[]' ); + viewDocument.on( 'keydown', keydownHandler ); + + viewDocument.fire( 'keydown', domEventDataMock ); + + expect( getModelData( doc ) ).to.equal( 'foo[]' ); + sinon.assert.calledOnce( domEventDataMock.preventDefault ); + sinon.assert.notCalled( keydownHandler ); + } ); + + it( 'should prevent default behaviour when there is no correct location - document start', () => { + const keydownHandler = sinon.spy(); + const domEventDataMock = { + keyCode: keyCodes.arrowleft, + preventDefault: sinon.spy(), + }; + setModelData( doc, '[]foo' ); + viewDocument.on( 'keydown', keydownHandler ); + + viewDocument.fire( 'keydown', domEventDataMock ); + + expect( getModelData( doc ) ).to.equal( '[]foo' ); + sinon.assert.calledOnce( domEventDataMock.preventDefault ); + sinon.assert.notCalled( keydownHandler ); + } ); + + test( + 'should move selection to object element - right arrow', + 'foo[]', + keyCodes.arrowright, + 'foo[]' + ); + + test( + 'should move selection to object element - down arrow', + 'foo[]', + keyCodes.arrowdown, + 'foo[]' + ); + + test( + 'should move selection to object element - left arrow', + '[]foo', + keyCodes.arrowleft, + '[]foo' + ); + + test( + 'should move selection to object element - up arrow', + '[]foo', + keyCodes.arrowup, + '[]foo' + ); + + test( + 'do nothing on non objects - right arrow', + 'foo[]bar', + keyCodes.arrowright, + 'foo[]bar' + ); + + test( + 'do nothing on non objects - down arrow', + 'foo[]bar', + keyCodes.arrowdown, + 'foo[]bar' + ); + + test( + 'do nothing on non objects - left arrow', + 'foo[]bar', + keyCodes.arrowleft, + 'foo[]bar' + ); + + test( + 'do nothing on non objects - up arrow', + 'foo[]bar', + keyCodes.arrowup, + 'foo[]bar' + ); + + test( + 'should work correctly with modifier key: right arrow + ctrl', + '[]foo', + { keyCode: keyCodes.arrowright, ctrlKey: true }, + '[]foo' + ); + + test( + 'should work correctly with modifier key: right arrow + alt', + '[]foo', + { keyCode: keyCodes.arrowright, altKey: true }, + '[]foo' + ); + + test( + 'should work correctly with modifier key: right arrow + shift', + '[]foo', + { keyCode: keyCodes.arrowright, shiftKey: true }, + '[]foo' + ); + + test( + 'should work correctly with modifier key: down arrow + ctrl', + '[]foo', + { keyCode: keyCodes.arrowdown, ctrlKey: true }, + '[]foo' + ); + + test( + 'should work correctly with modifier key: down arrow + alt', + '[]foo', + { keyCode: keyCodes.arrowdown, altKey: true }, + '[]foo' + ); + + test( + 'should work correctly with modifier key: down arrow + shift', + '[]foo', + { keyCode: keyCodes.arrowdown, shiftKey: true }, + '[]foo' + ); + + test( + 'should work correctly with modifier key: left arrow + ctrl', + 'foo[]', + { keyCode: keyCodes.arrowleft, ctrlKey: true }, + 'foo[]' + ); + + test( + 'should work correctly with modifier key: left arrow + alt', + 'foo[]', + { keyCode: keyCodes.arrowleft, altKey: true }, + 'foo[]' + ); + + test( + 'should work correctly with modifier key: left arrow + shift', + 'foo[]', + { keyCode: keyCodes.arrowleft, shiftKey: true }, + 'foo[]' + ); + + test( + 'should work correctly with modifier key: up arrow + ctrl', + 'foo[]', + { keyCode: keyCodes.arrowup, ctrlKey: true }, + 'foo[]' + ); + + test( + 'should work correctly with modifier key: up arrow + alt', + 'foo[]', + { keyCode: keyCodes.arrowup, altKey: true }, + 'foo[]' + ); + + test( + 'should work correctly with modifier key: up arrow + shift', + 'foo[]', + { keyCode: keyCodes.arrowup, shiftKey: true }, + 'foo[]' + ); + + test( + 'should do nothing if there is more than one selection in model', + '[foo][bar]', + keyCodes.arrowright, + '[foo][bar]' + ); + } ); + + function test( name, data, keyCodeOrMock, expected ) { + it( name, () => { + const domEventDataMock = ( typeof keyCodeOrMock == 'object' ) ? keyCodeOrMock : { + keyCode: keyCodeOrMock + }; + + setModelData( doc, data ); + viewDocument.fire( 'keydown', new DomEventData( + viewDocument, + { target: document.createElement( 'div' ), preventDefault: () => {} }, + domEventDataMock + ) ); + + expect( getModelData( doc ) ).to.equal( expected ); + } ); + } + } ); +} ); diff --git a/tests/widgetengine.js b/tests/widgetengine.js new file mode 100644 index 00000000..268e8a76 --- /dev/null +++ b/tests/widgetengine.js @@ -0,0 +1,97 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import WidgetEngine from '../src/widgetengine'; +import buildModelConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildmodelconverter'; +import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; +import ViewContainer from '@ckeditor/ckeditor5-engine/src/view/containerelement'; +import ViewEditable from '@ckeditor/ckeditor5-engine/src/view/editableelement'; +import { toWidget } from '../src/utils'; + +describe( 'WidgetEngine', () => { + let editor, document, viewDocument; + + beforeEach( () => { + return VirtualTestEditor.create( { + plugins: [ WidgetEngine ] + } ) + .then( newEditor => { + editor = newEditor; + document = editor.document; + viewDocument = editor.editing.view; + document.schema.registerItem( 'widget', '$block' ); + document.schema.registerItem( 'editable' ); + document.schema.allow( { name: '$inline', inside: 'editable' } ); + document.schema.allow( { name: 'editable', inside: 'widget' } ); + document.schema.allow( { name: 'editable', inside: '$root' } ); + + buildModelConverter().for( editor.editing.modelToView ) + .fromElement( 'widget' ) + .toElement( () => { + const element = toWidget( new ViewContainer( 'div' ), { label: 'element label' } ); + + return element; + } ); + + buildModelConverter().for( editor.editing.modelToView ) + .fromElement( 'editable' ) + .toElement( () => new ViewEditable( 'figcaption', { contenteditable: true } ) ); + } ); + } ); + + it( 'should be loaded', () => { + expect( editor.plugins.get( WidgetEngine ) ).to.be.instanceOf( WidgetEngine ); + } ); + + it( 'should apply fake view selection if model selection is on widget element', () => { + setModelData( document, '[foo bar]' ); + + expect( getViewData( viewDocument ) ).to.equal( + '[
foo bar
]' + ); + expect( viewDocument.selection.isFake ).to.be.true; + } ); + + it( 'should use element\'s label to set fake selection if one is provided', () => { + setModelData( document, '[foo bar]' ); + + expect( viewDocument.selection.fakeSelectionLabel ).to.equal( 'element label' ); + } ); + + it( 'fake selection should be empty if widget is not selected', () => { + setModelData( document, 'foo bar' ); + + expect( viewDocument.selection.fakeSelectionLabel ).to.equal( '' ); + } ); + + it( 'should toggle selected class', () => { + setModelData( document, '[foo]' ); + + expect( getViewData( viewDocument ) ).to.equal( + '[
foo
]' + ); + + document.enqueueChanges( () => { + document.selection.collapseToStart(); + } ); + + expect( getViewData( viewDocument ) ).to.equal( + '[]
foo
' + ); + } ); + + it( 'should do nothing when selection is placed in other editable', () => { + setModelData( document, 'foo bar[baz]' ); + + expect( getViewData( viewDocument ) ).to.equal( + '
' + + '
foo bar
' + + '
' + + '
{baz}
' + ); + } ); +} ); diff --git a/theme/theme.scss b/theme/theme.scss new file mode 100644 index 00000000..7ea54ee3 --- /dev/null +++ b/theme/theme.scss @@ -0,0 +1,41 @@ +// Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. +// For licensing, see LICENSE.md or http://ckeditor.com/license + +@import '../../ckeditor5-image/node_modules/@ckeditor/ckeditor5-theme-lark/theme/helpers/colors'; +@import '../../ckeditor5-image/node_modules/@ckeditor/ckeditor5-theme-lark/theme/helpers/shadow'; +@import '../../ckeditor5-image/node_modules/@ckeditor/ckeditor5-theme-lark/theme/helpers/states'; +@import '../../ckeditor5-image/node_modules/@ckeditor/ckeditor5-theme-lark/theme/helpers/spacing'; + +@include ck-color-add( ( + 'widget-blurred': #ddd, + 'widget-hover': #FFD25C +) ); + +$widget-outline-thickness: 3px; + +.ck-widget { + margin: ck-spacing() 0; + padding: 0; + + &.ck-widget_selected, &.ck-widget_selected:hover { + outline: $widget-outline-thickness solid ck-color( 'focus' ); + } + + .ck-editor__editable.ck-blurred &.ck-widget_selected { + outline: $widget-outline-thickness solid ck-color( 'widget-blurred' ); + } + + &:hover { + outline: $widget-outline-thickness solid ck-color( 'widget-hover' ); + } + + .ck-editable { + // The `:focus` style is applied before `.ck-editable_focused` class is rendered in the view. + // These styles show a different border for a blink of an eye, so `:focus` need to have same styles applied. + &.ck-editable_focused, &:focus { + @include ck-focus-ring( 'outline' ); + @include ck-box-shadow( $ck-inner-shadow ); + background-color: ck-color( 'background' ); + } + } +} From d0d8516475fcb89912aaa6d6f36f514f24e29d2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Szymon=20Kup=C5=9B?= Date: Fri, 31 Mar 2017 15:39:51 +0200 Subject: [PATCH 2/3] Fixed theme imports. --- theme/theme.scss | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/theme/theme.scss b/theme/theme.scss index 7ea54ee3..c042225d 100644 --- a/theme/theme.scss +++ b/theme/theme.scss @@ -1,10 +1,10 @@ // Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. // For licensing, see LICENSE.md or http://ckeditor.com/license -@import '../../ckeditor5-image/node_modules/@ckeditor/ckeditor5-theme-lark/theme/helpers/colors'; -@import '../../ckeditor5-image/node_modules/@ckeditor/ckeditor5-theme-lark/theme/helpers/shadow'; -@import '../../ckeditor5-image/node_modules/@ckeditor/ckeditor5-theme-lark/theme/helpers/states'; -@import '../../ckeditor5-image/node_modules/@ckeditor/ckeditor5-theme-lark/theme/helpers/spacing'; +@import '~@ckeditor/ckeditor5-theme-lark/theme/helpers/colors'; +@import '~@ckeditor/ckeditor5-theme-lark/theme/helpers/shadow'; +@import '~@ckeditor/ckeditor5-theme-lark/theme/helpers/states'; +@import '~@ckeditor/ckeditor5-theme-lark/theme/helpers/spacing'; @include ck-color-add( ( 'widget-blurred': #ddd, From 3a9e7744d63a155ee95609abecc5ec54c95a0af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotrek=20Koszuli=C5=84ski?= Date: Fri, 31 Mar 2017 15:44:10 +0200 Subject: [PATCH 3/3] Minor wording improvements in the readme, etc. --- LICENSE.md | 2 +- README.md | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/LICENSE.md b/LICENSE.md index 9aaefdea..1fc36622 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,7 +1,7 @@ Software License Agreement ========================== -**CKEditor 5 Image Feature** – https://github.com/ckeditor/ckeditor5-image
+**CKEditor 5 widget API** – https://github.com/ckeditor/ckeditor5-image
Copyright (c) 2003-2017, [CKSource](http://cksource.com) Frederico Knabben. All rights reserved. Licensed under the terms of any of the following licenses at your choice: diff --git a/README.md b/README.md index 57441d65..0b303cf7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -CKEditor 5 Widget Feature +CKEditor 5 widget API ======================================== [![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-widget.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-widget) @@ -7,7 +7,7 @@ CKEditor 5 Widget Feature [![Dependency Status](https://david-dm.org/ckeditor/ckeditor5-widget/status.svg)](https://david-dm.org/ckeditor/ckeditor5-widget) [![devDependency Status](https://david-dm.org/ckeditor/ckeditor5-widget/dev-status.svg)](https://david-dm.org/ckeditor/ckeditor5-widget?type=dev) -Widget feature for CKEditor 5. More information about the project can be found at the following URL: . +Widget API for CKEditor 5. More information about the project can be found at the following URL: . ## License diff --git a/package.json b/package.json index a941f9a4..bdeb894b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@ckeditor/ckeditor5-widget", "version": "0.0.1", - "description": "Widget feature for CKEditor 5.", + "description": "Widget API for CKEditor 5.", "keywords": [], "dependencies": { "@ckeditor/ckeditor5-core": "^0.7.0",