blob: 09cc9f3e8cea3fa5574b9198e52cda3d698d250a [file] [log] [blame]
/*******************************************************************************
* Copyright (c) 2013, 2014 EclipseSource.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* EclipseSource - initial API and implementation
******************************************************************************/
//@ sourceURL=AutoSuggest.js
///////////////////
// Event Delegation
var MODEL_KEY = "org.eclipse.rap.addons.autosuggest#Model";
function handleEvent( event ) {
if( event.widget ) {
var model = rap.getObject( event.widget.getData( MODEL_KEY ) );
if( event.widget.classname === "rwt.widgets.DropDown" ) {
handleDropDownEvent( model, event );
} else {
handleTextEvent( model, event );
}
} else {
handleModelEvent( event.source, event );
}
}
function handleModelEvent( model, event ) {
var textWidget;
try {
textWidget = rap.getObject( model.get( "textWidgetId" ) );
} catch( ex ) {
// When Text is disposed, AutoSuggest may perform a set operation before it is also disposed
return;
}
var dropDown = rap.getObject( model.get( "dropDownWidgetId" ) );
if( event.type === "accept" ) {
onAcceptSuggestion.apply( model, [ event ] );
} else {
switch( event.property ) {
case "dataSourceId":
onChangeDataSourceId.apply( model, [ event ] );
break;
case "suggestions":
onChangeSuggestions.apply( model, [ event ] );
break;
case "userText":
onChangeUserText.apply( model, [ event ] );
break;
case "suggestionsVisible":
syncModelSuggestionsVisible.apply( model, [ dropDown, event ] );
break;
case "currentSuggestions":
onChangeCurrentSuggestions.apply( model, [ event ] );
break;
case "currentSuggestionTexts":
syncModelCurrentSuggestionTexts.apply( model, [ dropDown, event ] );
break;
case "selectedSuggestionIndex":
onChangeSelectedSuggestionIndex.apply( event.source, [ event ] );
break;
case "replacementText":
onChangeReplacementText.apply( event.source, [ event ] );
break;
case "text":
syncModelText.apply( model, [ textWidget, event ] );
break;
case "textSelection":
syncModelTextSelection.apply( model, [ textWidget, event ] );
break;
}
}
}
function handleDropDownEvent( model, event ) {
switch( event.type ) {
case SWT.Show:
case SWT.Hide:
syncDropDownVisible.apply( model, [ event.widget, event ] );
break;
case SWT.Selection:
syncDropDownSelection.apply( model, [ event.widget, event ] );
break;
case SWT.DefaultSelection:
forwardDropDownDefaultSelection.apply( model, [ event.widget, event ] );
break;
}
}
function handleTextEvent( model, event ) {
var userAction = getUserAction( event );
switch( event.type ) {
case SWT.Modify:
syncTextText.apply( model, [ event.widget, event, userAction ] );
break;
}
setUserAction( event );
}
////////////////////////////////
// Synchronize Model <-> Widgets
function syncTextText( textWidget, event, userAction ) {
var text = textWidget.getText();
this.set( "text", text, { "action" : "sync" } );
if( userAction ) {
this.set( "userText", text, { "action" : userAction } );
}
}
function syncDropDownVisible( dropDown, event ) {
this.set( "suggestionsVisible", dropDown.getVisible(), { "action" : "sync" } );
}
function syncModelSuggestionsVisible( dropDown, event ) {
if( event.options.action !== "sync" ) {
dropDown.setVisible( event.value );
}
}
function syncDropDownSelection( dropDown, event ) {
this.set( "selectedSuggestionIndex", event.index, { "action" : "sync" } );
}
function forwardDropDownDefaultSelection( dropDown, event ) {
this.notify( "accept", { type : "accept", "source" : this } );
}
function syncModelCurrentSuggestionTexts( dropDown, event ) {
dropDown.setItems( this.get( "currentSuggestionTexts" ) );
}
function syncModelText( textWidget, event ) {
if( event.options.action !== "sync" ) {
textWidget.setText( event.value );
}
}
function syncModelTextSelection( textWidget, event ) {
textWidget.setSelection( event.value );
}
//////////////////
// Event Handling
function onChangeDataSourceId( event ) {
this.set( "suggestions", null );
}
function onChangeSuggestions( event ) {
// NOTE: Nothing else to do if not visible, but would need to update when it becomes visible.
// Currently only onChangeUserText can set resultsVisible to true, which updates implicitly.
if( this.get( "suggestionsVisible" ) ) {
filterSuggestions.apply( this, [ { "action" : "refresh" } ] );
}
}
function onChangeUserText( event ) {
this.set( "suggestionsVisible", event.value != null && event.value.length > 0 );
filterSuggestions.apply( this, [ event.options ] );
}
function onChangeCurrentSuggestions( event ) {
var action = event.options.action;
var currentSuggestions = this.get( "currentSuggestions" );
if( this.get( "autoComplete" ) && ( action === "typing" || action === "refresh" ) ) {
var common = commonText( map( currentSuggestions, getReplacementText ) );
if( common && common.length > this.get( "userText" ).length ) {
this.set( "replacementText", common );
}
}
ensureTemplate.apply( this );
var template = this.get( "template" );
this.set( "currentSuggestionTexts", currentSuggestions.map( template ) );
}
function onChangeSelectedSuggestionIndex( event ) {
var suggestion = null;
if( event.value !== -1 ) {
suggestion = this.get( "currentSuggestions" )[ event.value ] || "";
}
var replacementText = getReplacementText( suggestion );
if( replacementText != null ) {
this.set( "replacementText", null, { "action" : "sync" } );
}
this.set( "replacementText", replacementText, { "action" : "selection" } );
}
function onChangeReplacementText( event ) {
if( event.options.action !== "sync" ) {
var userText = this.get( "userText" ) || ""; // TODO : could overwrite server set text?
var text = event.value || userText;
this.set( "text", text );
if( event.options.action === "selection" ) {
if( event.value === null ) {
this.set( "textSelection", [ text.length , text.length ] );
} else {
this.set( "textSelection", [ 0, text.length ] );
}
} else {
// TODO : not always working?
this.set( "textSelection", [ userText.length, text.length ] );
}
}
}
function onAcceptSuggestion( event ) {
var currentSuggestions = this.get( "currentSuggestions" );
if( currentSuggestions ) {
var index = this.get( "selectedSuggestionIndex" );
var suggestionSelected = typeof index === "number" && index > -1;
var autoCompleteAccepted = this.get( "autoComplete" )
&& currentSuggestions.length === 1
&& currentSuggestions[ 0 ] == this.get( "text" );
if( suggestionSelected || autoCompleteAccepted ) {
this.notify( "suggestionSelected" );
this.set( "suggestionsVisible", false );
}
}
var text = this.get( "text" ) || "";
this.set( "textSelection", [ text.length, text.length ] );
}
function filterSuggestions( options ) {
fetchSuggestions.apply( this );
var userText = this.get( "userText" ) || "";
this.set( "replacementText", null, { "action" : "sync" } );
ensureFilter.apply( this );
var filter = this.get( "filter" );
var filterWrapper = function( suggestion ) {
return filter( suggestion, userText );
};
var currentSuggestions = filterArray( this.get( "suggestions" ), filterWrapper );
this.set( "currentSuggestions", currentSuggestions, { "action" : options.action } );
if( options.action === "typing" && currentSuggestions.length === 1 ) {
onAcceptSuggestion.apply( this, [ { "options" : options } ] );
}
}
function fetchSuggestions() {
if( this.get( "suggestions" ) == null ) {
var dataSource = getDataSource.apply( this );
if( dataSource != null ) {
this.set( "suggestions", dataSource.get( "data" ) );
} else {
this.set( "suggestions", [] );
}
}
}
var defaultFilter = function( suggestion, userText ) {
var text = getReplacementText( suggestion );
return text.toLowerCase().indexOf( userText.toLowerCase() ) === 0;
};
function ensureFilter() {
if( this.get( "filter" ) == null ) {
var dataSource = getDataSource.apply( this );
if( dataSource != null && dataSource.get( "filterScript" ) != null ) {
try {
this.set( "filter",
secureEval( "var result = " + dataSource.get( "filterScript" ) + "; result;" ) );
} catch( ex ) {
throw new Error( "AutoSuggest could not eval filter function: " + ex.message );
}
} else {
this.set( "filter", defaultFilter );
}
}
}
// TODO [tb] : there should be default templates for string, array and object
var defaultTemplate = function( suggestion ) {
return suggestion instanceof Array ? suggestion.slice( 1 ).join( "\t" ) : suggestion;
};
function ensureTemplate() {
if( this.get( "template" ) == null ) {
var dataSource = getDataSource.apply( this );
if( dataSource != null && dataSource.get( "templateScript" ) != null ) {
try {
this.set( "template",
secureEval( "var result = " + dataSource.get( "templateScript" ) + "; result;" ) );
} catch( ex ) {
throw new Error( "AutoSuggest could not eval template function: " + ex.message );
}
} else {
this.set( "template", defaultTemplate );
}
}
}
function getDataSource() {
if( this.get( "dataSourceId" ) != null ) {
return rap.getObject( this.get( "dataSourceId" ) );
}
return null;
}
function getReplacementText( suggestion ) {
return suggestion instanceof Array ? suggestion[ 0 ] : suggestion;
}
////////////////////////
// Helper - autoComplete
function commonText( texts ) {
var result = null;
if( texts.length === 1 ) {
result = texts[ 0 ];
} else if( texts.length > 1 ) {
var common = commonChars( texts );
if( common.length > 0 ) {
if( allTextsSplitAt( texts, common.length ) ) {
result = common;
} else {
var splitRegExp = /\W/g;
var matches = common.match( splitRegExp );
if( matches && matches.length > 0 ) {
var lastSplitCharOffset = common.lastIndexOf( matches.pop() );
result = common.slice( 0, lastSplitCharOffset + 1 );
}
}
}
}
return result;
}
function commonChars( texts ) {
var testItem = texts[ 0 ];
var result = "";
var matches = true;
for( var offset = 0; ( offset < testItem.length ) && matches; offset++ ) {
var candidate = result + testItem.charAt( offset );
for( var i = 0; i < texts.length; i++ ) {
if( texts[ i ].indexOf( candidate ) !== 0 ) {
matches = false;
break;
}
}
if( matches ) {
result = candidate;
}
}
return result;
}
// TODO [tb] : refactor to every( texts, hasSplitCharacterAt )
function allTextsSplitAt( texts, offset ) {
var splitRegExp = /\W/; // not a letter/digit/underscore
var result = true;
for( var i = 0; i < texts.length; i++ ) {
if( texts[ i ].length !== offset ) {
var itemChar = texts[ i ].charAt( offset );
if( !splitRegExp.test( itemChar ) ) {
result = false;
}
}
}
return result;
}
/////////
// Helper
function filterArray( array, filter, limit ) {
var result = [];
if( typeof array.filter === "function" && limit > 0 ) {
result = array.filter( filter );
} else {
for( var i = 0; i < array.length; i++ ) {
if( filter( array[ i ], i ) ) {
result.push( array[ i ] );
if( result.length === limit ) {
break;
}
}
}
}
return result;
}
function secureEval() {
// TODO : protect against global var access
return eval( arguments[ 0 ] );
}
function map( array, func ) {
if( typeof array.map === "function" ) {
return array.map( func );
} else {
var result = [];
for( var i = 0; i < array.length; i++ ) {
result[ i ] = func( array[ i ] );
}
return result;
}
}
function setUserAction( event ) {
if( event.type === SWT.Verify ) {
// See Bug 404896 - [ClientScripting] Verify event keyCode is always zero when replacing txt
var action = ( event.text !== "" /* && event.keyCode !== 0 */ ) ? "typing" : "deleting";
event.widget.setData( "userAction", action );
}
}
function getUserAction( event ) {
var action = event.widget.getData( "userAction" );
event.widget.setData( "userAction", null );
return action;
}