mediawiki-extensions-Visual.../modules/ve/ui/widgets/ve.ui.MWLinkTargetInputWidget.js
Catrope 2eb0d2a6b2 Great Annotation Refactor of 2013
This changes the annotation API to be the same as the node API, sans
a few boolean flags that don't apply. The APIs were different, but
there was really no good reason why, so this makes things simpler for
API users. It also means we'll be able to factor a bunch of things out
because they're now duplicated between nodes, meta items and annotations.

Linear model annotations are now objects with 'type' and 'attributes'
properties (rather than 'name' and 'data'), for consistency with elements.
They now also contain html/0/* attributes for HTML attribute preservation,
which obsoletes the htmlTagName and htmlAttributes properties.
dm.Annotation subclasses take a reference to such an object and implement
conversion using .static.toDataElement and .static.toDomElements just
like nodes do. The custom .getHash() functions are no longer necessary
because of the way HTML attribute preservation was reimplemented.

CE rendering has been moved out of dm.Annotation (it never made sense to
have CE rendering functions in DM classes, this was bothering me) and into
separate ce.Annotation subclasses. These are very similar to CE nodes in
that they have a this.$ generated based on something in the DM; the main
difference is that nodes listen to events and update themselves, whereas
annotations are static and are simply destroyed and rebuilt when they
change. This change also adds whitelisted HTML attribute rendering for
annotations, as well as class="ve-ce-FooAnnotation" attributes.

Now that annotation classes produce real DOM nodes rather than weird
objects describing HTML tags, we can't generate HTML as a string in
ce.ContentBranchNode anymore. getRenderedContents() has been rewritten
to be much more similar to the way the converter renders annotations;
in fact, significant parts of it were copied from the converter, so that
should be factored out in the future. This change actually fixes an
annotation rendering discrepancy between ce.ContentBranchNode and
dm.Converter; see the diff of ve.ce.ContentBranchNode.test.js.

ve.ce.MWEntityNode.js:
* Remove stray property

ve.dm.MWExternalLinkAnnotation.js:
* Store 'rel' attribute

ve.dm.TextStyleAnnotation.js:
* Put all the conversion logic in the abstract base class

ve.dm.Converter.js:
* Also feed annotations through getDomElementsFromDataElement() and
  createDataElement()

ve.dm.Node.js:
* Fix undocumented property

ve.ce.ContentBranchNode.test.js:
* Add descriptive messages for each test case
* Compare DOM trees, not HTML strings
* Compare without all the class="ve-ce-WhateverAnnotation" clutter

ve.ui.LinkInspector.js:
* Replace direct .getHash() calls (evil!) with ve.getHash()

Bug: 46464
Bug: 44808
Change-Id: I31991488579b8cce6d98ed8b29b486ba5ec38cdc
2013-04-08 18:10:16 -07:00

410 lines
10 KiB
JavaScript

/*!
* VisualEditor UserInterface MWLinkTargetInputWidget class.
*
* @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
* @license The MIT License (MIT); see LICENSE.txt
*/
/*global mw*/
/**
* Creates an ve.ui.MWLinkTargetInputWidget object.
*
* @class
* @extends ve.ui.LinkTargetInputWidget
*
* @constructor
* @param {Object} [config] Config options
* @cfg {jQuery} [$overlay=this.$$( 'body' )] Element to append menu to
*/
ve.ui.MWLinkTargetInputWidget = function VeUiMWLinkTargetInputWidget( config ) {
// Config intialization
config = config || {};
// Parent constructor
ve.ui.LinkTargetInputWidget.call( this, config );
// Properties
this.$overlay = config.$overlay || this.$$( 'body' );
this.menu = new ve.ui.TextInputMenuWidget(
this, { '$$': ve.ui.get$$( this.$overlay ), 'input': this }
);
this.annotation = null;
this.existingPages = {};
this.matchingPages = {};
this.existingPagesQuery = null;
this.existingPagesRequest = null;
this.matchingPagesQuery = null;
this.matchingPagesRequest = null;
this.previousMatches = null;
// Events
this.$overlay.append( this.menu.$ );
this.$input.on( {
'click': ve.bind( this.onClick, this ),
'focus': ve.bind( this.onFocus, this ),
'blur': ve.bind( this.onBlur, this )
} );
this.menu.on( 'select', ve.bind( this.onMenuItemSelect, this ) );
this.addListenerMethods( this, {'change': 'onChange'} );
// Initialization
this.$.addClass( 've-ui-mwLinkTargetInputWidget' );
this.menu.$.addClass( 've-ui-mwLinkTargetInputWidget-menu' );
};
/* Inheritance */
ve.inheritClass( ve.ui.MWLinkTargetInputWidget, ve.ui.LinkTargetInputWidget );
/* Methods */
/**
* Handles click events.
*
* @method
* @param {jQuery.Event} e Mouse click event
*/
ve.ui.MWLinkTargetInputWidget.prototype.onClick = function () {
if ( !this.disabled ) {
this.openMenu();
}
};
/**
* Handles focus events.
*
* @method
* @param {jQuery.Event} e Input focus event
*/
ve.ui.MWLinkTargetInputWidget.prototype.onFocus = function () {
if ( !this.disabled ) {
this.openMenu();
}
};
/**
* Handles blur events.
*
* @method
* @param {jQuery.Event} e Input blur
*/
ve.ui.MWLinkTargetInputWidget.prototype.onBlur = function () {
this.menu.hide();
};
/**
* Handles change events.
*
* @method
* @param {ve.ui.MenuItemWidget} item Menu item
*/
ve.ui.MWLinkTargetInputWidget.prototype.onMenuItemSelect = function ( item ) {
if ( item ) {
this.setAnnotation( item.getData() );
}
};
/**
* Opens the suggestion menu on input change.
*
*
* @method
* @param {string} value New value
*/
ve.ui.MWLinkTargetInputWidget.prototype.onChange = function () {
this.openMenu();
};
/**
* Set the value of the input.
*
* Overrides setValue to keep annotations in sync.
*
* @method
* @param {string} value New value
*/
ve.ui.MWLinkTargetInputWidget.prototype.setValue = function ( value ) {
// Keep annotation in sync with value, call parent method.
ve.ui.TextInputWidget.prototype.setValue.call( this, value );
};
/**
* Opens the menu.
*
* @method
* @chainable
*/
ve.ui.MWLinkTargetInputWidget.prototype.openMenu = function () {
this.populateMenu();
this.queryPageExistence();
this.queryMatchingPages();
if ( this.value.length && !this.menu.isVisible() ) {
this.menu.show();
}
return this;
};
/**
* Populates the menu.
*
* @method
* @chainable
*/
ve.ui.MWLinkTargetInputWidget.prototype.populateMenu = function () {
var i, len,
menu$$ = this.menu.$$,
items = [],
pageExists = this.existingPages[this.value],
matchingPages = this.matchingPages[this.value];
// Reset
this.menu.clearItems();
// Hide on empty target
if ( !this.value.length ) {
this.menu.hide();
return this;
}
// External link
if ( ve.init.platform.getExternalLinkUrlProtocolsRegExp().test( this.value ) ) {
items.push( new ve.ui.MenuSectionItemWidget(
'externalLink', { '$$': menu$$, 'label': 'External link' }
) );
items.push( new ve.ui.MenuItemWidget(
this.getExternalLinkAnnotationFromUrl( this.value ),
{ '$$': menu$$, 'rel': 'externalLink', 'label': this.value }
) );
}
// Internal link
if ( !pageExists && ( !matchingPages || matchingPages.indexOf( this.value ) === -1 ) ) {
items.push( new ve.ui.MenuSectionItemWidget(
'newPage', { '$$': menu$$, 'label': 'New page' }
) );
items.push( new ve.ui.MenuItemWidget(
this.getInternalLinkAnnotationFromTitle( this.value ),
{ '$$': menu$$, 'rel': 'newPage', 'label': this.value }
) );
}
// Matching pages
if ( matchingPages && matchingPages.length ) {
items.push( new ve.ui.MenuSectionItemWidget(
'matchingPages', { '$$': menu$$, 'label': 'Matching page' }
) );
for ( i = 0, len = matchingPages.length; i < len; i++ ) {
items.push( new ve.ui.MenuItemWidget(
new ve.dm.MWInternalLinkAnnotation( { 'title': matchingPages[i] } ),
{ '$$': menu$$, 'rel': 'matchingPage', 'label': matchingPages[i] }
) );
}
this.previousMatches = matchingPages;
}
// Add items
this.menu.addItems( items );
// Auto-select
this.menu.selectItem( this.menu.getItemFromData( this.annotation ), true );
if ( !this.menu.getSelectedItem() ) {
this.menu.selectItem( this.menu.getClosestSelectableItem( 0 ), true );
}
this.menu.highlightItem( this.menu.getSelectedItem() );
return this;
};
/**
* Signals that an response is pending.
*
* @method
* @chainable
*/
ve.ui.MWLinkTargetInputWidget.prototype.pushPending = function () {
this.pending++;
this.$.addClass( 've-ui-mwLinkTargetInputWidget-pending' );
return this;
};
/**
* Signals that an response is complete.
*
* @method
* @chainable
*/
ve.ui.MWLinkTargetInputWidget.prototype.popPending = function () {
this.pending--;
this.$.removeClass( 've-ui-mwLinkTargetInputWidget-pending' );
return this;
};
/**
* Gets an internal link annotation.
*
* File: or Category: links will be prepended with a colon so they are interpreted as a links rather
* than image inclusions or categorizations.
*
* @method
* @param {string} target Page title
* @returns {ve.dm.MWInternalLinkAnnotation}
*/
ve.ui.MWLinkTargetInputWidget.prototype.getInternalLinkAnnotationFromTitle = function ( target ) {
var title;
try {
title = new mw.Title( target );
if ( title.getNamespaceId() === 6 || title.getNamespaceId() === 14 ) {
target = ':' + target;
}
} catch ( e ) { }
return new ve.dm.MWInternalLinkAnnotation( {
'type': 'link/MWinternal',
'attributes': {
'title': target
}
} );
};
/**
* Gets an external link annotation.
*
* @method
* @param {string} target Web address
* @returns {ve.dm.MWExternalLinkAnnotation}
*/
ve.ui.MWLinkTargetInputWidget.prototype.getExternalLinkAnnotationFromUrl = function ( target ) {
return new ve.dm.MWExternalLinkAnnotation( {
'type': 'link/MWexternal',
'attributes': {
'href': target
}
} );
};
/**
* Gets a target from an annotation.
*
* @method
* @param {ve.dm.MWExternalLinkAnnotation|ve.dm.MWInternalLinkAnnotation} annotation Annotation
* @returns {string} Target
*/
ve.ui.MWLinkTargetInputWidget.prototype.getTargetFromAnnotation = function ( annotation ) {
if ( annotation instanceof ve.dm.MWExternalLinkAnnotation ) {
return annotation.getAttribute( 'href' );
} else if ( annotation instanceof ve.dm.MWInternalLinkAnnotation ) {
return annotation.getAttribute( 'title' );
}
return '';
};
/**
* Checks page existence for the current value.
*
* {ve.ui.MWLinkTargetInputWidget.populateMenu} will be called immediately if the page existence has
* been cached, or as soon as the API returns a result.
*
* @method
* @chainable
*/
ve.ui.MWLinkTargetInputWidget.prototype.queryPageExistence = function () {
if ( this.existingPagesQuery === this.value ) {
// Ignore duplicate requests
return;
}
if ( this.existingPagesRequest ) {
this.existingPagesRequest.abort();
this.existingPagesQuery = null;
this.existingPagesRequest = null;
}
if ( this.value in this.existingPages ) {
this.populateMenu();
} else {
this.pushPending();
this.existingPagesQuery = this.value;
this.existingPagesRequest = $.ajax( {
'url': mw.util.wikiScript( 'api' ),
'data': {
'format': 'json',
'action': 'query',
'indexpageids': '',
'titles': this.value,
'converttitles': ''
},
'dataType': 'json',
'success': ve.bind( function ( data ) {
this.existingPagesQuery = null;
this.existingPagesRequest = null;
var page,
exists = false;
if ( data.query ) {
page = data.query.pages[data.query.pageids[0]];
exists = ( page.missing === undefined && page.invalid === undefined );
// Cache result for normalized title
this.existingPages[page.title] = exists;
}
// Cache result for original input
this.existingPages[this.value] = exists;
this.populateMenu();
}, this ),
'complete': ve.bind( function () {
this.popPending();
}, this )
} );
}
return this;
};
/**
* Checks matching pages for the current value.
*
* {ve.ui.MWLinkTargetInputWidget.populateMenu} will be called immediately if matching pages have
* been cached, or as soon as the API returns a result.
*
* @method
* @chainable
*/
ve.ui.MWLinkTargetInputWidget.prototype.queryMatchingPages = function () {
if ( this.matchingPagesQuery === this.value ) {
// Ignore duplicate requests
return;
}
if ( this.matchingPagesRequest ) {
this.matchingPagesRequest.abort();
this.matchingPagesQuery = null;
this.matchingPagesRequest = null;
}
if ( this.value in this.matchingPages ) {
this.populateMenu();
} else {
this.pushPending();
this.matchingPagesQuery = this.value;
this.matchingPagesRequest = $.ajax( {
'url': mw.util.wikiScript( 'api' ),
'data': {
'format': 'json',
'action': 'opensearch',
'search': this.value,
'namespace': 0,
'suggest': ''
},
'dataType': 'json',
'success': ve.bind( function ( data ) {
this.matchingPagesQuery = null;
this.matchingPagesRequest = null;
if ( ve.isArray( data ) && data.length ) {
// Cache the matches to the query
this.matchingPages[this.value] = data[1];
this.populateMenu();
} else {
// Don't repeat queries that resulted in invalid responses
this.matchingPages[this.value] = [];
}
}, this ),
'complete': ve.bind( function () {
this.popPending();
}, this )
} );
}
return this;
};