mediawiki-extensions-Visual.../modules/ve/ui/inspectors/ve.ui.LinkInspector.js
Trevor Parscal 353297e5b5 (bug 42925) Inspector doesn't open properly
ve.Range
* Rewrote truncate so that it works as expected, truncation should always reduce the length using the start/end values, not the from/to values

ve.ui.Inspector
* Added a comment about where the name argument to onBeforeInspectorOpen comes from, since it's a little bit confusing on first read (and I wrote it!)
* Calling onInitialize, onOpen and onClose methods directly, since we need to control the before or after-ness of listeners getting in there and doing their stuff - plus it's more direct
* Removed onRemove stub, which is never actually called
* Added before/after versions of initialize, open and close events
* Got rid of recursion guard since we don't need it anymore thanks to changes made in ve.dm.Surface (see below)

ve.ui.Context
* Updated event names to deal with new before/after naming of initialize, open and close events
* Removed fade-in logic since fading in doesn't even work anymore - since now we now annotate first, then open the inspector, the menu will actually exist and be open when we open the inspector even though you don't see it because it's quickly obscured

ve.ui.LinkInspector
* Made fragments non-auto selecting, in the case of onInitialize we actually call select(), which is silly since we were using an auto-selecting fragment - it's clear that this was a mistake

ve.dm.Surface
* Moved locking (polling stop and start) to the far outside edges of the change method
* I need a lot of eyes and testing on this change, it seems OK to me, but I'm suspicious that it may have side effects
* What was happening is that selection changes were being applied and then the poll was picking them up, and then the selection was coming in again as a change, but it wasn't a change at all, it was just feedback - this change event was then closing the inspector the instant it was opened - the odd part was that this only occurred when you selected backwards, which seems to be caused by the range being normalized, so it looked like a new selection even though it wasn't

ve.dm.Document
* trimOuterSpace from Range didn't consider annotated spaces to be spaces, by using the [0] trick (first character of a plain text character string or first element in an annotated character's array both are the character's value - but elements don't have a property named '0' so it skips those safely as well) we can always get the right value for comparison

Change-Id: I0873d906c058203b83b8d4bbe5a4b274f05a26fd
2012-12-10 16:48:13 -08:00

386 lines
10 KiB
JavaScript

/*global mw */
/**
* VisualEditor user interface LinkInspector class.
*
* @copyright 2011-2012 VisualEditor Team and others; see AUTHORS.txt
* @license The MIT License (MIT); see LICENSE.txt
*/
/**
* Creates an ve.ui.LinkInspector object.
*
* @class
* @constructor
* @extends {ve.ui.Inspector}
* @param context
*/
ve.ui.LinkInspector = function VeUiLinkInspector( context ) {
// Parent constructor
ve.ui.Inspector.call( this, context );
// Properties
this.context = context;
this.initialTarget = null;
this.isNewAnnotation = false;
this.$locationInput = this.frame.$$(
'<input type="text" class="ve-ui-linkInspector-location ve-ui-icon-down" />'
);
// Initialization
this.$form.append( this.$locationInput );
// FIXME: MediaWiki-specific
if ( 'mw' in window ) {
this.initMultiSuggest();
}
};
/* Inheritance */
ve.inheritClass( ve.ui.LinkInspector, ve.ui.Inspector );
/* Static properties */
ve.ui.LinkInspector.static.icon = 'link';
ve.ui.LinkInspector.static.titleMessage = 'visualeditor-linkinspector-title';
ve.ui.LinkInspector.static.typePattern = /^link(\/MW(in|ex)ternal)?$/;
/* Methods */
/**
* Responds to the inspector being initialized.
*
* There are 4 scenarios:
* * Zero-length selection not near a word -> no change, text will be inserted on close
* * Zero-length selection inside or adjacent to a word -> expand selection to cover word
* * Selection covering non-link text -> trim selection to remove leading/trailing whitespace
* * Selection covering link text -> expand selection to cover link
*
* @method
*/
ve.ui.LinkInspector.prototype.onInitialize = function () {
var fragment = this.context.getSurface().getModel().getFragment( null, true ),
annotation = this.getMatchingAnnotations( fragment ).get( 0 );
if ( !annotation ) {
if ( fragment.getRange().isCollapsed() ) {
// Expand to nearest word
fragment = fragment.expandRange( 'word' );
} else {
// Trim whitespace
fragment = fragment.trimRange();
}
if ( !fragment.getRange().isCollapsed() ) {
// Create annotation from selection
fragment.annotateContent(
'set', this.getAnnotationFromTarget( fragment.truncateRange( 255 ).getText() )
);
this.isNewAnnotation = true;
}
} else {
// Expand range to cover annotation
fragment = fragment.expandRange( 'annotation', annotation );
}
// Update selection
fragment.select();
};
/**
* Responds to the inspector being opened.
*
* @method
*/
ve.ui.LinkInspector.prototype.onOpen = function () {
var target = '',
fragment = this.context.getSurface().getModel().getFragment( null, true ),
annotation = this.getMatchingAnnotations( fragment ).get( 0 );
if ( annotation ) {
if ( annotation instanceof ve.dm.MWInternalLinkAnnotation ) {
// Internal link
target = annotation.data.title || '';
} else {
// External link
target = annotation.data.href || '';
}
}
this.initialTarget = target;
// Initialize form
this.$locationInput.val( target );
// Set focus on the location input
setTimeout( ve.bind( function () {
this.$locationInput.focus().select();
}, this ), 0 );
};
/**
* Responds to the inspector being opened.
*
* @method
* @param {Boolean} remove Annotation should be removed
*/
ve.ui.LinkInspector.prototype.onClose = function ( remove ) {
var i, len, annotations,
insert = false,
undo = false,
clear = false,
set = false,
target = this.$locationInput.val(),
surface = this.context.getSurface(),
selection = surface.getModel().getSelection(),
fragment = surface.getModel().getFragment( this.initialSelection, false );
// Empty target is a shortcut for removal
if ( target === '' ) {
remove = true;
}
if ( remove ) {
clear = true;
} else {
if ( this.initialSelection.isCollapsed() ) {
insert = true;
}
if ( target !== this.initialTarget ) {
if ( this.isNewAnnotation ) {
undo = true;
} else {
clear = true;
}
set = true;
}
}
if ( insert ) {
// Insert default text and select it
fragment = fragment.insertContent( target, false ).adjustRange( -target.length );
}
if ( undo ) {
// Go back to before we added an annotation in an onInitialize handler
surface.execute( 'history', 'undo' );
}
if ( clear ) {
// Clear all existing annotations
annotations = this.getMatchingAnnotations( fragment ).get();
for ( i = 0, len = annotations.length; i < len; i++ ) {
fragment.annotateContent( 'clear', annotations[i] );
}
}
if ( set ) {
// Apply new annotation
fragment.annotateContent( 'set', this.getAnnotationFromTarget( target ) );
}
// Selection changes may have occured in the insertion and annotation hullabaloo - restore it
surface.execute( 'content', 'select', selection );
// Reset state
this.isNewAnnotation = false;
};
/**
* Gets an annotation object from a target.
*
* The type of link is automatically detected based on some crude heuristics.
*
* @method
* @param {String} target Link target
* @returns {ve.dm.Annotation}
*/
ve.ui.LinkInspector.prototype.getAnnotationFromTarget = function ( target ) {
var title, annotation;
// FIXME: MediaWiki-specific
if ( 'mw' in window ) {
// Figure out if this is an internal or external link
if ( target.match( /^(https?:)?\/\// ) ) {
// External link
annotation = new ve.dm.MWExternalLinkAnnotation();
annotation.data.href = target;
} else {
// Internal link
// TODO: In the longer term we'll want to have autocompletion and existence and validity
// checks using AJAX
try {
title = new mw.Title( target );
if ( title.getNamespaceId() === 6 || title.getNamespaceId() === 14 ) {
// File: or Category: link
// We have to prepend a colon so this is interpreted as a link
// rather than an image inclusion or categorization
target = ':' + target;
}
} catch ( e ) { }
annotation = new ve.dm.MWInternalLinkAnnotation();
annotation.data.title = target;
}
} else {
// Default to generic external link
annotation = new ve.dm.LinkAnnotation();
annotation.data.href = target;
}
return annotation;
};
/**
* Initalizes the multi-suggest plugin for the location input.
*
* TODO: Consider cleaning up and organizing this all a bit.
*
* @method
*/
ve.ui.LinkInspector.prototype.initMultiSuggest = function () {
var options,
inspector = this,
context = inspector.context,
$overlay = context.$overlay,
suggestionCache = {},
pageStatusCache = {},
api = new mw.Api();
function updateLocationStatus( status ) {
inspector.$locationInput.data( 'status', status );
}
// Multi Suggest configuration.
options = {
'parent': $overlay,
'prefix': 've-ui',
// Disable CSS Ellipsis.
// Using MediaWiki jQuery.autoEllipsis() for center ellipsis.
'cssEllipsis': false,
// Build suggestion groups in order.
'suggestions': function ( params ) {
var modifiedQuery, title, prot,
groups = {},
results = params.results,
query = params.query;
// Add existing pages.
if ( results.length > 0 ) {
groups.existingPage = {
'label': ve.msg( 'visualeditor-linkinspector-suggest-existing-page' ),
'items': results,
'itemClass': 've-ui-suggest-item-existingPage'
};
}
// Run the query through the mw.Title object to handle correct capitalization,
// whitespace and and namespace alias/localization resolution.
try {
title = new mw.Title( query );
modifiedQuery = title.getPrefixedText();
// If page doesn't exist, add New Page group.
if ( ve.indexOf( modifiedQuery, results ) === -1 ) {
groups.newPage = {
'label': ve.msg( 'visualeditor-linkinspector-suggest-new-page' ),
'items': [modifiedQuery],
'itemClass': 've-ui-suggest-item-newPage'
};
}
} catch ( e ) {
// invalid input
ve.log( e );
}
// Add external
groups.externalLink = {
'label': ve.msg( 'visualeditor-linkinspector-suggest-external-link' ),
'items': [],
'itemClass': 've-ui-suggest-item-externalLink'
};
// Find a protocol and suggest an external link.
prot = query.match(
ve.init.platform.getExternalLinkUrlProtocolsRegExp()
);
if ( prot ) {
groups.externalLink.items = [query];
// No protocol, default to http
} else {
groups.externalLink.items = ['http://' + query];
}
return groups;
},
// Called on succesfull input.
'input': function ( callback ) {
var $input = $( this ),
query = $input.val(),
cKey = query.toLowerCase();
// Query page and set status data on the location input.
if ( pageStatusCache[query] !== undefined ) {
updateLocationStatus( pageStatusCache[query] );
} else {
api.get( {
'action': 'query',
'indexpageids': '',
'titles': query,
'converttitles': ''
} )
.done( function ( data ) {
var status, page;
if ( data.query ) {
page = data.query.pages[data.query.pageids[0]];
status = 'exists';
if ( page.missing !== undefined ) {
status = 'notexists';
} else if ( page.invalid !== undefined ) {
status = 'invalid';
}
}
// Cache the status of the link query.
pageStatusCache[query] = status;
updateLocationStatus( status );
} );
}
// Set overlay position.
options.position();
// Build from cache.
if ( suggestionCache[cKey] !== undefined ) {
callback( {
'query': query,
'results': suggestionCache[cKey]
} );
} else {
// Load immediate suggestions
callback( {
'query': query,
'results': []
} );
// Build from fresh api request.
api.get( {
'action': 'opensearch',
'search': query
} )
.done( function ( data ) {
suggestionCache[cKey] = data[1];
// Build
callback( {
'query': query,
'results': data[1]
} );
} );
}
},
// Called when multiSuggest dropdown is updated.
'update': function () {
// Ellipsis
$( '.ve-ui-suggest-item' )
.autoEllipsis( {
'hasSpan': true,
'tooltip': true
} );
},
// Position the iframe overlay below the input.
'position': function () {
context.positionOverlayBelow( $overlay, inspector.$locationInput );
},
// Fired when a suggestion is selected.
'select': function () {
// Assume page suggestion is valid.
updateLocationStatus( 'valid' );
}
};
// Setup Multi Suggest
this.$locationInput.multiSuggest( options );
};
/* Registration */
ve.ui.inspectorFactory.register( 'link', ve.ui.LinkInspector );