/*!
* VisualEditor UserInterface CompletionWidget class.
*
* @copyright See AUTHORS.txt
*/
/**
* Widget that displays autocompletion suggestions
*
* @class
* @extends OO.ui.Widget
*
* @constructor
* @param {ve.ui.Surface} surface Surface to complete into
* @param {Object} [config] Configuration options
* @param {Object} [config.validate] Validation pattern passed to TextInputWidgets
* @param {boolean} [config.readOnly=false] Prevent changes to the value of the widget.
*/
ve.ui.CompletionWidget = function VeUiCompletionWidget( surface, config ) {
this.surface = surface;
this.surfaceModel = surface.getModel();
// Configuration
config = config || {
anchor: false
};
// Parent constructor
ve.ui.CompletionWidget.super.call( this, config );
this.$tabIndexed = this.$element;
const $doc = surface.getView().getDocument().getDocumentNode().$element;
this.popup = new OO.ui.PopupWidget( {
anchor: false,
align: 'forwards',
hideWhenOutOfView: false,
autoFlip: false,
width: null,
$container: config.$popupContainer || this.surface.$element,
containerPadding: config.popupPadding
} );
this.input = new OO.ui.TextInputWidget();
this.menu = new OO.ui.MenuSelectWidget( {
widget: this,
$input: $doc.add( this.input.$input )
} );
// This may be better semantically as a MenuSectionOptionWidget,
// but that causes all subsequent options to be indented.
this.header = new OO.ui.MenuOptionWidget( {
classes: [ 've-ui-completionWidget-header' ],
disabled: true
} );
this.noResults = new OO.ui.MenuOptionWidget( {
label: ve.msg( 'visualeditor-completionwidget-noresults' ),
classes: [ 've-ui-completionWidget-noresults' ],
disabled: true
} );
// Events
this.menu.connect( this, {
choose: 'onMenuChoose',
toggle: 'onMenuToggle'
} );
this.input.connect( this, { change: 'update' } );
this.popup.$element.prepend( this.input.$element );
this.popup.$body.append(
this.menu.$element
);
// Setup
this.$element.addClass( 've-ui-completionWidget' )
.append(
this.popup.$element
);
};
/* Inheritance */
OO.inheritClass( ve.ui.CompletionWidget, OO.ui.Widget );
/**
* Setup the completion widget
*
* @param {ve.ui.Action} action Action which opened the widget
* @param {boolean} [isolateInput] Isolate input from the surface
*/
ve.ui.CompletionWidget.prototype.setup = function ( action, isolateInput ) {
const range = this.surfaceModel.getSelection().getCoveringRange();
this.action = action;
this.isolateInput = !!isolateInput;
this.sequenceLength = this.action.getSequenceLength();
this.initialOffset = range.end - this.sequenceLength;
this.input.toggle( this.isolateInput );
if ( this.isolateInput ) {
this.wasActive = !this.surface.getView().isDeactivated();
this.surface.getView().deactivate();
this.input.setValue( '' );
setTimeout( () => {
this.input.focus();
}, 1 );
} else {
this.wasActive = false;
}
this.update();
this.surfaceModel.connect( this, { select: 'onModelSelect' } );
};
/**
* Teardown the completion widget
*/
ve.ui.CompletionWidget.prototype.teardown = function () {
this.tearingDown = true;
this.popup.toggle( false );
this.surfaceModel.disconnect( this );
if ( this.wasActive ) {
this.surface.getView().activate();
}
this.action = undefined;
this.tearingDown = false;
};
/**
* Update the completion widget after the input has changed
*/
ve.ui.CompletionWidget.prototype.update = function () {
const direction = this.surface.getDir(),
range = this.getCompletionRange(),
boundingRect = this.surface.getView().getSelection( new ve.dm.LinearSelection( range ) ).getSelectionBoundingRect(),
style = {
top: boundingRect.bottom
};
let input;
if ( this.isolateInput ) {
input = this.input.getValue();
} else {
const data = this.surfaceModel.getDocument().data;
input = data.getText( false, range );
}
if ( direction === 'rtl' ) {
// This works because this.$element is a 0x0px box, with the menu positioned relative to it.
// If this style was applied to the menu, we'd need to do some math here to align the right
// edge of the menu with the right edge of the selection.
style.left = boundingRect.right;
} else {
style.left = boundingRect.left;
}
this.$element.css( style );
this.updateMenu( input );
this.action.getSuggestions( input ).then( ( suggestions ) => {
if ( !this.action ) {
// Check widget hasn't been torn down
return;
}
this.menu.clearItems();
let menuItems = suggestions.map( this.action.getMenuItemForSuggestion.bind( this.action ) );
menuItems = this.action.updateMenuItems( menuItems );
this.menu.addItems( menuItems );
this.menu.highlightItem( this.menu.findFirstSelectableItem() );
this.updateMenu( input, suggestions );
} );
};
/**
* Update the widget's menu with the latest suggestions
*
* @param {string} input Input text
* @param {Array} suggestions Suggestions
*/
ve.ui.CompletionWidget.prototype.updateMenu = function ( input, suggestions ) {
// Update the header based on the input
const label = this.action.getHeaderLabel( input, suggestions );
if ( label !== undefined ) {
this.header.setLabel( label );
}
if ( this.header.getLabel() !== null ) {
this.menu.addItems( [ this.header ], 0 );
} else {
this.menu.removeItems( [ this.header ] );
}
if ( !this.isolateInput ) {
// If there is a header or menu items, show the menu
if ( this.menu.items.length ) {
this.menu.toggle( true );
this.popup.toggle( true );
// Menu may have changed size, so recalculate position
this.popup.updateDimensions();
} else {
this.popup.toggle( false );
}
} else {
if ( !this.menu.items.length ) {
this.menu.addItems( [ this.noResults ], 0 );
}
this.menu.toggle( true );
this.popup.toggle( true );
this.popup.updateDimensions();
}
};
/**
* Handle choose events from the menu
*
* @param {OO.ui.MenuOptionWidget} item Chosen option
*/
ve.ui.CompletionWidget.prototype.onMenuChoose = function ( item ) {
this.action.chooseItem( item, this.getCompletionRange( true ) );
this.teardown();
};
/**
* Handle toggle events from the menu
*
* @param {boolean} visible Menu is visible
*/
ve.ui.CompletionWidget.prototype.onMenuToggle = function ( visible ) {
if ( !visible && !this.tearingDown ) {
// Menu was hidden by the user (e.g. pressed ESC) - trigger a teardown
this.teardown();
}
};
/**
* Handle select events from the document model
*
* @param {ve.dm.Selection} selection Selection
*/
ve.ui.CompletionWidget.prototype.onModelSelect = function () {
const range = this.getCompletionRange();
const countMatches = () => {
let matches = this.menu.getItems().length;
if ( this.header.getLabel() !== null ) {
matches--;
}
if ( this.action.constructor.static.alwaysIncludeInput ) {
matches--;
}
return matches;
};
if ( !range || range.isBackwards() || this.action.shouldAbandon( this.surfaceModel.getDocument().data.getText( false, range ), countMatches() ) ) {
this.teardown();
} else {
this.update();
}
};
/**
* Get the range where the user has entered text in the document since opening the widget
*
* @param {boolean} [withSequence] Include the triggering sequence text in the range
* @return {ve.Range|null} Range, null if not valid
*/
ve.ui.CompletionWidget.prototype.getCompletionRange = function ( withSequence ) {
const range = this.surfaceModel.getSelection().getCoveringRange();
if ( !range || !this.action ) {
return null;
}
return new ve.Range( this.initialOffset + ( withSequence ? 0 : this.sequenceLength ), range.end );
};