Selaa lähdekoodia

Add custom item renderer handling to mentions view.

Maciej Gołaszewski 6 vuotta sitten
vanhempi
commit
7b07574d35

+ 86 - 13
packages/ckeditor5-mention/src/mentionui.js

@@ -13,9 +13,11 @@ import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
 import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import View from '@ckeditor/ckeditor5-ui/src/view';
+
 import MentionsView from './ui/mentionsview';
 import TextWatcher from './textwatcher';
-import View from '@ckeditor/ckeditor5-ui/src/view';
 
 /**
  * The mention ui feature.
@@ -50,6 +52,25 @@ export default class MentionUI extends Plugin {
 
 		this._createMentionView( editor );
 
+		this.editor.editing.view.document.on( 'keydown', ( evt, data ) => {
+			if ( isHandledKey( data.keyCode ) && this.panelView.isVisible ) {
+				data.preventDefault();
+				evt.stop(); // Required for enter overriding.
+
+				if ( data.keyCode == keyCodes.arrowdown ) {
+					this._mentionsView.selectNext();
+				}
+
+				if ( data.keyCode == keyCodes.arrowup ) {
+					this._mentionsView.selectPrevious();
+				}
+
+				if ( data.keyCode == keyCodes.enter || data.keyCode == keyCodes.tab ) {
+					this._mentionsView.executeSelected();
+				}
+			}
+		}, { priority: 'highest' } ); // priority highest required for enter overriding.
+
 		this._mentionsConfigurations = new Map();
 
 		const config = this.editor.config.get( 'mention' );
@@ -83,32 +104,43 @@ export default class MentionUI extends Plugin {
 
 			const renderer = this._getItemRenderer( marker );
 
-			const listItemView = new ListItemView( editor.locale );
+			const listItemView = new MentionListItemView( editor.locale );
 
 			if ( renderer ) {
 				const domNode = renderer( item );
 
-				listItemView.children.add( new DomWrapperView( editor.locale, domNode ) );
+				const domWrapperView = new DomWrapperView( editor.locale, domNode );
+				domWrapperView.delegate( 'execute' ).to( listItemView );
+				listItemView.children.add( domWrapperView );
 			} else {
 				const buttonView = new ButtonView( editor.locale );
 
 				buttonView.label = label;
 				buttonView.withText = true;
-				buttonView.item = item;
-				buttonView.marker = marker;
 
 				listItemView.children.add( buttonView );
 
-				buttonView.delegate( 'execute' ).to( this._mentionsView );
+				buttonView.delegate( 'execute' ).to( listItemView );
 			}
 
+			listItemView.item = item;
+			listItemView.marker = marker;
+
+			// TODO maybe delegate would be better.
+			listItemView.on( 'execute', () => {
+				this._mentionsView.fire( 'execute', {
+					item,
+					marker
+				} );
+			} );
+
 			return listItemView;
 		} );
 
-		this._mentionsView.on( 'execute', evt => {
-			// @todo use event data
-			const label = evt.source.label;
-			const marker = evt.source.marker;
+		this._mentionsView.on( 'execute', ( evt, data ) => {
+			const item = data.item;
+			const label = item.label || item;
+			const marker = data.marker;
 
 			const watcher = this._getWatcher( marker );
 
@@ -195,6 +227,7 @@ export default class MentionUI extends Plugin {
 	_showPanel() {
 		this.panelView.pin( this._getBalloonPositionData() );
 		this.panelView.show();
+		this._mentionsView.selectFirst();
 	}
 
 	_hidePanel() {
@@ -233,8 +266,8 @@ function getBalloonPositions() {
 	const defaultPositions = BalloonPanelView.defaultPositions;
 
 	return [
-		defaultPositions.northArrowSouthWest,
-		defaultPositions.southArrowNorthWest
+		defaultPositions.southArrowNorthWest,
+		defaultPositions.northArrowSouthWest
 	];
 }
 
@@ -265,7 +298,7 @@ function createTextMatcher( marker ) {
 function createFeedCallback( feedItems ) {
 	return feedText => {
 		const filteredItems = feedItems.filter( item => {
-			return item.toLowerCase().startsWith( feedText.toLowerCase() );
+			return item.toLowerCase().includes( feedText.toLowerCase() );
 		} );
 
 		return Promise.resolve( filteredItems );
@@ -278,6 +311,20 @@ class DomWrapperView extends View {
 
 		this.template = false;
 		this.domNode = domNode;
+
+		this.domNode.classList.add( 'ck-button' );
+
+		this.set( 'isOn', false );
+
+		this.on( 'change:isOn', ( evt, name, isOn ) => {
+			if ( isOn ) {
+				this.domNode.classList.add( 'ck-on' );
+				this.domNode.classList.remove( 'ck-off' );
+			} else {
+				this.domNode.classList.add( 'ck-off' );
+				this.domNode.classList.remove( 'ck-on' );
+			}
+		} );
 	}
 
 	render() {
@@ -286,3 +333,29 @@ class DomWrapperView extends View {
 		this.element = this.domNode;
 	}
 }
+
+function isHandledKey( keyCode ) {
+	const handledKeyCodes = [
+		keyCodes.arrowup,
+		keyCodes.arrowdown,
+		keyCodes.arrowleft,
+		keyCodes.arrowright,
+		keyCodes.enter,
+		keyCodes.tab
+	];
+	return handledKeyCodes.includes( keyCode );
+}
+
+class MentionListItemView extends ListItemView {
+	highlight() {
+		const child = this.children.first;
+
+		child.isOn = true;
+	}
+
+	removeHighlight() {
+		const child = this.children.first;
+
+		child.isOn = false;
+	}
+}

+ 47 - 0
packages/ckeditor5-mention/src/ui/mentionsview.js

@@ -38,4 +38,51 @@ export default class MentionsView extends View {
 			]
 		} );
 	}
+
+	selectFirst() {
+		this.select( 0 );
+	}
+
+	selectNext() {
+		const item = this.selected;
+
+		const index = this.listView.items.getIndex( item );
+
+		this.select( index + 1 );
+	}
+
+	selectPrevious() {
+		const item = this.selected;
+
+		const index = this.listView.items.getIndex( item );
+
+		this.select( index - 1 );
+	}
+
+	select( index ) {
+		let indexToGet = 0;
+
+		if ( index > 0 && index < this.listView.items.length ) {
+			indexToGet = index;
+		} else if ( index < 0 ) {
+			indexToGet = this.listView.items.length - 1;
+		}
+
+		const item = this.listView.items.get( indexToGet );
+		item.highlight();
+
+		if ( this.selected ) {
+			this.selected.removeHighlight();
+		}
+
+		this.selected = item;
+	}
+
+	executeSelected() {
+		if ( !this.selected ) {
+			return;
+		}
+
+		this.selected.fire( 'execute' );
+	}
 }

+ 18 - 1
packages/ckeditor5-mention/tests/manual/mention-custom-renderer.html

@@ -1,5 +1,5 @@
 <div id="editor">
-	<p>Hello <span class="mention" data-mention="Ted">@Ted</span>.</p>
+	<p>Have you met... </p>
 </div>
 
 <style>
@@ -7,4 +7,21 @@
 		background: hsla(341, 100%, 87%, 0.4);
 		color: hsl(340, 100%, 31%);
 	}
+
+	.custom-item {
+		display: block;
+		padding: 1em;
+	}
+
+	.custom-item.ck-on {
+		color: white;
+	}
+
+	.custom-item .custom-item-username {
+		color: #666;
+	}
+
+	.custom-item.ck-on .custom-item-username{
+		color: #ddd;
+	}
 </style>

+ 18 - 9
packages/ckeditor5-mention/tests/manual/mention-custom-renderer.js

@@ -29,19 +29,14 @@ ClassicEditor
 		toolbar: [ 'heading', '|', 'bold', 'italic', 'underline', 'link', '|', 'insertTable', '|', 'undo', 'redo' ],
 		mention: [
 			{
-				feed: feedText => Promise.resolve( [
-					{ id: '1', label: 'Barney' },
-					{ id: '2', label: 'Lily' },
-					{ id: '3', label: 'Marshall' },
-					{ id: '4', label: 'Robin' },
-					{ id: '5', label: 'Ted' }
-				].filter( item => item.label.toLowerCase().includes( feedText ) ) ),
+				feed: getFeed,
 				itemRenderer: item => {
 					const span = global.document.createElementNS( 'http://www.w3.org/1999/xhtml', 'span' );
 
-					span.innerHTML = `<span id="${ item.id }">@${ item.label }</span>`;
+					span.classList.add( 'custom-item' );
+					span.id = `mention-list-item-id-${ item.id }`;
 
-					console.log( 'rendered node: ', span, item );
+					span.innerHTML = `${ item.label } <span class="custom-item-username">@${ item.username }</span>`;
 
 					return span;
 				}
@@ -54,3 +49,17 @@ ClassicEditor
 	.catch( err => {
 		console.error( err.stack );
 	} );
+
+function getFeed( feedText ) {
+	return Promise.resolve( [
+		{ id: '1', label: 'Barney Stinson', username: 'swarley' },
+		{ id: '2', label: 'Lily Aldrin', username: 'lilypad' },
+		{ id: '3', label: 'Marshall Eriksen', username: 'marshmallow' },
+		{ id: '4', label: 'Robin Scherbatsky', username: 'rsparkles' },
+		{ id: '5', label: 'Ted Mosby', username: 'tdog' }
+	].filter( item => {
+		const searchString = feedText.toLowerCase();
+
+		return item.label.toLowerCase().includes( searchString ) || item.username.toLowerCase().includes( searchString );
+	} ) );
+}

+ 2 - 2
packages/ckeditor5-mention/tests/manual/mention.html

@@ -1,6 +1,6 @@
 <div id="editor">
-	<p>Hello <span class="mention" data-mention="John">@John</span>.</p>
-	<p>Hello <span class="mention" data-mention="John">@John</span><span class="mention" data-mention="John">@John</span>.</p>
+	<p>Hello <span class="mention" data-mention="Ted">@Ted</span>.</p>
+	<p>Hello <span class="mention" data-mention="Ted">@Ted</span><span class="mention" data-mention="Ted">@Ted</span>.</p>
 </div>
 
 <style>

+ 46 - 28
packages/ckeditor5-mention/tests/mentionui.js

@@ -19,7 +19,7 @@ import MentionsView from '../src/ui/mentionsview';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 describe( 'BalloonToolbar', () => {
-	let editor, model, doc, editingView, mentionUI, editorElement;
+	let editor, model, doc, editingView, mentionUI, editorElement, mentionsView, panelView, listView;
 
 	const staticConfig = [
 		{ feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ] }
@@ -59,19 +59,19 @@ describe( 'BalloonToolbar', () => {
 
 		describe( 'panelView', () => {
 			it( 'should create a view instance', () => {
-				expect( mentionUI.panelView ).to.instanceof( BalloonPanelView );
+				expect( panelView ).to.instanceof( BalloonPanelView );
 			} );
 
 			it( 'should be added to the ui.view.body collection', () => {
-				expect( Array.from( editor.ui.view.body ) ).to.include( mentionUI.panelView );
+				expect( Array.from( editor.ui.view.body ) ).to.include( panelView );
 			} );
 
 			it( 'should have disabled arrow', () => {
-				expect( mentionUI.panelView.withArrow ).to.be.false;
+				expect( panelView.withArrow ).to.be.false;
 			} );
 
 			it( 'should have added MentionView as a child', () => {
-				expect( mentionUI.panelView.content.get( 0 ) ).to.be.instanceof( MentionsView );
+				expect( panelView.content.get( 0 ) ).to.be.instanceof( MentionsView );
 			} );
 		} );
 	} );
@@ -91,8 +91,8 @@ describe( 'BalloonToolbar', () => {
 
 				return waitForDebounce()
 					.then( () => {
-						expect( mentionUI.panelView.isVisible ).to.be.true;
-						expect( mentionUI._mentionsView.listView.items ).to.have.length( 5 );
+						expect( panelView.isVisible ).to.be.true;
+						expect( listView.items ).to.have.length( 5 );
 					} );
 			} );
 
@@ -109,8 +109,23 @@ describe( 'BalloonToolbar', () => {
 
 				return waitForDebounce()
 					.then( () => {
-						expect( mentionUI.panelView.isVisible ).to.be.true;
-						expect( mentionUI._mentionsView.listView.items ).to.have.length( 1 );
+						expect( panelView.isVisible ).to.be.true;
+						expect( listView.items ).to.have.length( 1 );
+					} );
+			} );
+
+			it( 'should focus the first item in panel', () => {
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '@', doc.selection.getFirstPosition() );
+				} );
+
+				return waitForDebounce()
+					.then( () => {
+						const button = listView.items.get( 0 ).children.get( 0 );
+
+						expect( button.isOn ).to.be.true;
 					} );
 			} );
 
@@ -122,7 +137,7 @@ describe( 'BalloonToolbar', () => {
 				} );
 
 				return waitForDebounce()
-					.then( () => expect( mentionUI.panelView.isVisible ).to.be.true )
+					.then( () => expect( panelView.isVisible ).to.be.true )
 					.then( () => {
 						model.change( writer => {
 							writer.insertText( 'x', doc.selection.getFirstPosition() );
@@ -130,8 +145,8 @@ describe( 'BalloonToolbar', () => {
 					} )
 					.then( waitForDebounce )
 					.then( () => {
-						expect( mentionUI.panelView.isVisible ).to.be.false;
-						expect( mentionUI._mentionsView.listView.items ).to.have.length( 0 );
+						expect( panelView.isVisible ).to.be.false;
+						expect( listView.items ).to.have.length( 0 );
 					} );
 			} );
 
@@ -143,7 +158,7 @@ describe( 'BalloonToolbar', () => {
 				} );
 
 				return waitForDebounce()
-					.then( () => expect( mentionUI.panelView.isVisible ).to.be.true )
+					.then( () => expect( panelView.isVisible ).to.be.true )
 					.then( () => {
 						model.change( writer => {
 							const end = doc.selection.getFirstPosition();
@@ -153,7 +168,7 @@ describe( 'BalloonToolbar', () => {
 						} );
 					} )
 					.then( waitForDebounce )
-					.then( () => expect( mentionUI.panelView.isVisible ).to.be.false );
+					.then( () => expect( panelView.isVisible ).to.be.false );
 			} );
 		} );
 
@@ -184,8 +199,8 @@ describe( 'BalloonToolbar', () => {
 
 				return waitForDebounce()
 					.then( () => {
-						expect( mentionUI.panelView.isVisible ).to.be.true;
-						expect( mentionUI._mentionsView.listView.items ).to.have.length( 4 );
+						expect( panelView.isVisible ).to.be.true;
+						expect( listView.items ).to.have.length( 4 );
 					} );
 			} );
 
@@ -202,8 +217,8 @@ describe( 'BalloonToolbar', () => {
 
 				return waitForDebounce()
 					.then( () => {
-						expect( mentionUI.panelView.isVisible ).to.be.true;
-						expect( mentionUI._mentionsView.listView.items ).to.have.length( 1 );
+						expect( panelView.isVisible ).to.be.true;
+						expect( listView.items ).to.have.length( 1 );
 					} );
 			} );
 
@@ -215,7 +230,7 @@ describe( 'BalloonToolbar', () => {
 				} );
 
 				return waitForDebounce()
-					.then( () => expect( mentionUI.panelView.isVisible ).to.be.true )
+					.then( () => expect( panelView.isVisible ).to.be.true )
 					.then( () => {
 						model.change( writer => {
 							writer.insertText( 'x', doc.selection.getFirstPosition() );
@@ -223,8 +238,8 @@ describe( 'BalloonToolbar', () => {
 					} )
 					.then( waitForDebounce )
 					.then( () => {
-						expect( mentionUI.panelView.isVisible ).to.be.false;
-						expect( mentionUI._mentionsView.listView.items ).to.have.length( 0 );
+						expect( panelView.isVisible ).to.be.false;
+						expect( listView.items ).to.have.length( 0 );
 					} );
 			} );
 
@@ -236,7 +251,7 @@ describe( 'BalloonToolbar', () => {
 				} );
 
 				return waitForDebounce()
-					.then( () => expect( mentionUI.panelView.isVisible ).to.be.true )
+					.then( () => expect( panelView.isVisible ).to.be.true )
 					.then( () => {
 						model.change( writer => {
 							const end = doc.selection.getFirstPosition();
@@ -246,7 +261,7 @@ describe( 'BalloonToolbar', () => {
 						} );
 					} )
 					.then( waitForDebounce )
-					.then( () => expect( mentionUI.panelView.isVisible ).to.be.false );
+					.then( () => expect( panelView.isVisible ).to.be.false );
 			} );
 		} );
 	} );
@@ -285,8 +300,8 @@ describe( 'BalloonToolbar', () => {
 
 			return waitForDebounce()
 				.then( () => {
-					expect( mentionUI.panelView.isVisible ).to.be.true;
-					expect( mentionUI._mentionsView.listView.items ).to.have.length( 3 );
+					expect( panelView.isVisible ).to.be.true;
+					expect( listView.items ).to.have.length( 3 );
 				} );
 		} );
 	} );
@@ -306,7 +321,7 @@ describe( 'BalloonToolbar', () => {
 
 			return waitForDebounce()
 				.then( () => {
-					mentionUI._mentionsView.listView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
+					listView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
 
 					sinon.assert.calledOnce( spy );
 
@@ -332,9 +347,9 @@ describe( 'BalloonToolbar', () => {
 
 			return waitForDebounce()
 				.then( () => {
-					mentionUI._mentionsView.listView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
+					listView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
 
-					expect( mentionUI.panelView.isVisible ).to.be.false;
+					expect( panelView.isVisible ).to.be.false;
 				} );
 		} );
 	} );
@@ -351,6 +366,9 @@ describe( 'BalloonToolbar', () => {
 				doc = model.document;
 				editingView = editor.editing.view;
 				mentionUI = editor.plugins.get( MentionUI );
+				panelView = mentionUI.panelView;
+				mentionsView = mentionUI._mentionsView;
+				listView = mentionsView.listView;
 
 				editingView.attachDomRoot( editorElement );