Fixed inspector behavior

ve.ui.Inspector
* Removed disabled state and interfaces - this isn't needed
* Renamed prepareSelection to onInitialize
* Using event emitter to run onInitialize, onOpen and onClose methods
* Left removal up to the child class to handle in the onClose method
* Replaced calls on context to close inspector to calling close directly
* Renamed prepareSelection stub to onInitialize
* Emitting initialize event from within the open method
* Added recursion guarding to close method
* Changed the close method's argument to be remove instead of accept - the more common case is to save changes, and the only time you wouldn't save changes is if you were to remove the annotation
* Moved focus restore to close method

ve.ui.Context
* Moved the majority of the code in openInspector and closeInspector to event handlers for onInspectorOpen and onInspectorClose
* Updated calls to closeInspector re: accept->remove argument change

ve.ui.LinkInspector
* Renamed prepareSelection to onInitialize and rewrote logic and documentation
* Removed unused onLocationInputChange method
* Moved restore focus (now it's in the inspector base class)

ve.dm.SurfaceFragment
* Added word mode for expandRange

ve.dm.Surface
* Added locking/unlocking while processing transactions - this was not an issue before because this was effectively being done manually throughout ce (which needs to be cleaned up) but once we started using the content action to insert content dm and ce started playing off each other and inserting in a loop - we already do this for undo/redo so it makes sense to do it here as well

ve.InspectorAction
* Updated arguments re: close method's accept->remove argument change

Change-Id: I38995d4101fda71bfb2e6fe516603507ce820937
This commit is contained in:
Trevor Parscal 2012-11-20 14:51:24 -08:00
parent a54441611c
commit 8fc98868c9
6 changed files with 198 additions and 166 deletions

View file

@ -52,8 +52,8 @@ ve.InspectorAction.prototype.open = function ( name ) {
* @method
* @param {Boolean} accept Accept changes
*/
ve.InspectorAction.prototype.close = function ( accept ) {
this.surface.getContext().closeInspector( accept );
ve.InspectorAction.prototype.close = function ( remove ) {
this.surface.getContext().closeInspector( remove );
};
/* Registration */

View file

@ -218,6 +218,7 @@ ve.dm.Surface.prototype.change = function ( transactions, selection ) {
transactions = [transactions];
}
this.emit( 'lock' );
for ( i = 0; i < transactions.length; i++ ) {
if ( !transactions[i].isNoOp() ) {
this.bigStack = this.bigStack.slice( 0, this.bigStack.length - this.undoIndex );
@ -226,6 +227,7 @@ ve.dm.Surface.prototype.change = function ( transactions, selection ) {
ve.dm.TransactionProcessor.commit( this.getDocument(), transactions[i] );
}
}
this.emit( 'unlock' );
}
if ( selection && ( !this.selection || !this.selection.equals ( selection ) ) ) {
selection.normalize();

View file

@ -42,6 +42,12 @@ ve.dm.SurfaceFragment = function VeDmSurfaceFragment( surface, range, noAutoSele
);
};
/* Static Members */
ve.dm.SurfaceFragment.static = {};
ve.dm.SurfaceFragment.static.wordPattern = /[^\w']+/;
/* Methods */
/**
@ -174,6 +180,7 @@ ve.dm.SurfaceFragment.prototype.trimRange = function () {
*
* @method
* @param {String} [scope=parent] Method of expansion:
* 'word': Expands to cover the nearest word by looking for word boundary characters
* 'annotation': Expands to cover a given annotation (argument) within the current range
* 'root': Expands to cover the entire document
* 'siblings': Expands to cover all sibling nodes
@ -187,8 +194,27 @@ ve.dm.SurfaceFragment.prototype.expandRange = function ( scope, type ) {
if ( !this.surface ) {
return this;
}
var range, node, nodes, parent;
var before, after, range, node, nodes, parent,
wordPattern = this.constructor.static.wordPattern;
switch ( scope || 'parent' ) {
case 'word':
before = this.document.getText(
new ve.Range(
this.document.getNearestContentOffset( this.range.start - 64 ),
this.document.getNearestContentOffset( this.range.start )
)
).split( wordPattern );
after = this.document.getText(
new ve.Range(
this.document.getNearestContentOffset( this.range.end ),
this.document.getNearestContentOffset( this.range.end + 64 )
)
).split( wordPattern );
range = new ve.Range(
this.range.start - before[before.length - 1].length,
this.range.start + after[0].length
);
break;
case 'annotation':
range = this.document.getAnnotatedRangeFromSelection( this.range, type );
// Adjust selection if it does not contain the annotated range

View file

@ -51,22 +51,40 @@ ve.ui.LinkInspector.static.typePattern = /^link(\/MW(in|ex)ternal)?$/;
/* Methods */
/**
* Responds to location input change events.
* Responds to the inspector being initialized.
*
* This will be triggered from a variety of events including those from mouse, keyboard and
* clipboard actions.
* 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.onLocationInputChange = function() {
// Some events, such as keydown, fire before the value has actually changed - waiting for the
// call stack to clear will ensure that we have access to the new value as soon as possible
setTimeout( ve.bind( function () {
this.setDisabled(
this.$locationInput.val() === '' ||
this.$locationInput.data( 'status' ) === 'invalid'
ve.ui.LinkInspector.prototype.onInitialize = function () {
var fragment = this.context.getSurface().getModel().getFragment(),
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 ), 0 );
this.isNewAnnotation = true;
}
} else {
// Expand range to cover annotation
fragment = fragment.expandRange( 'annotation', annotation );
}
// Update selection
fragment.select();
};
/**
@ -90,10 +108,8 @@ ve.ui.LinkInspector.prototype.onOpen = function () {
}
this.initialTarget = target;
// Update controls
this.reset();
// Initialize form
this.$locationInput.val( target );
this.setDisabled( this.$locationInput.val().length === 0 );
// Set focus on the location input
setTimeout( ve.bind( function () {
@ -105,64 +121,60 @@ ve.ui.LinkInspector.prototype.onOpen = function () {
* Responds to the inspector being opened.
*
* @method
* @param {Boolean} remove Annotation should be removed
*/
ve.ui.LinkInspector.prototype.onClose = function ( accept ) {
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 );
if ( accept && target && target !== this.initialTarget ) {
if ( this.isNewAnnotation ) {
// Go back to before we add an annotation in prepareSelection
surface.execute( 'history', 'undo' );
// Restore selection to be sure we are still working on the same range
surface.execute( 'content', 'select', selection );
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;
this.context.getSurface().getView().getDocument().getDocumentNode().$.focus();
};
/**
* Returns the form to it's initial state.
*
* @method
*/
ve.ui.LinkInspector.prototype.reset = function () {
this.$locationInput.val( '' );
};
/**
* Prepares the inspector to be opened.
*
* Selection will be fixed up so that if there's an existing link the complete link is selected,
* otherwise the range will be contracted so there is no leading and trailing whitespace.
*
* @method
*/
ve.ui.LinkInspector.prototype.prepareSelection = function () {
var fragment = this.context.getSurface().getModel().getFragment(),
annotation = this.getMatchingAnnotations( fragment ).get( 0 );
if ( !annotation ) {
// Create annotation from selection
fragment = fragment.trimRange();
fragment.annotateContent(
'set', this.getAnnotationFromTarget( fragment.truncateRange( 255 ).getText() )
);
} else {
// Expand range to cover annotation
fragment = fragment.expandRange( 'annotation', annotation );
}
// Update selection
fragment.select();
};
/**
@ -223,11 +235,6 @@ ve.ui.LinkInspector.prototype.initMultiSuggest = function () {
pageStatusCache = {},
api = new mw.Api();
function updateLocationStatus( status ) {
if ( status !== 'invalid' ) {
inspector.$.removeClass( 've-ui-inspector-disabled' );
} else {
inspector.$.addClass( 've-ui-inspector-disabled' );
}
inspector.$locationInput.data( 'status', status );
}

View file

@ -100,6 +100,46 @@ ve.ui.Context.prototype.onSelectionEnd = function () {
this.update();
};
/**
* Responds to an inspector being opened.
*
* @method
* @param {String} name Name of inspector being opened
*/
ve.ui.Context.prototype.onInspectorOpen = function ( name ) {
var inspector = this.inspectors[name];
// Close menu
if ( this.menu ) {
this.obscure( this.$menu );
}
// Fade in context if menu is closed - at this point, menu could be null or not open
if ( this.menu === null || !this.menu.isOpen() ) {
this.$.fadeIn( 'fast' );
}
// Remember which inspector is open
this.inspector = name;
// Resize frame to the size of the inspector.
this.frame.setSize( inspector.$.outerWidth(), inspector.$.outerHeight() );
// Cache selection, in the case of manually opened inspector.
this.selection = this.surface.getModel().getSelection();
// Show context
this.show();
};
/**
* Responds to an inspector being closed.
*
* @method
* @param {String} name Name of inspector being closed
* @param {Boolean} remove Annotation should be removed
*/
ve.ui.Context.prototype.onInspectorClose = function () {
this.obscure( this.$inspectors );
this.inspector = null;
this.hide();
this.update();
};
/**
* Gets the surface this context is being used in.
*
@ -194,7 +234,7 @@ ve.ui.Context.prototype.show = function () {
ve.ui.Context.prototype.hide = function () {
if ( this.inspector ) {
this.closeInspector( true );
this.closeInspector();
this.$overlay.hide();
}
if ( this.menu ) {
@ -251,6 +291,8 @@ ve.ui.Context.prototype.initInspector = function ( name ) {
if ( ve.ui.inspectorFactory.lookup( name ) ) {
if ( !( name in this.inspectors ) ) {
inspector = this.inspectors[name] = ve.ui.inspectorFactory.create( name, this );
inspector.on( 'open', ve.bind( this.onInspectorOpen, this, name ) );
inspector.on( 'close', ve.bind( this.onInspectorClose, this ) );
inspector.$.hide();
this.frame.$.append( inspector.$ );
this.obscure( this.$inspectors );
@ -261,48 +303,24 @@ ve.ui.Context.prototype.initInspector = function ( name ) {
};
ve.ui.Context.prototype.openInspector = function ( name ) {
// Auto-initialize the inspector
if ( !this.initInspector( name ) ) {
throw new Error( 'Missing inspector. Can not open nonexistent inspector: ' + name );
}
var inspector = this.inspectors[name];
// Prepare the inspector to be opened
inspector.prepareSelection();
// HACK: prepareSelection probably caused an annotationChange event which closed the context
// before we could even open it - by executing the rest of this function later we can let the
// stack clear and then finally open the context and inspector once the dust has settled.
setTimeout( ve.bind( function () {
// Close menu
if ( this.menu ) {
this.obscure( this.$menu );
// Only allow one inspector open at a time
if ( this.inspector ) {
this.closeInspector();
}
// Fade in context if menu is closed.
// At this point, menu could be null or not open.
if ( this.menu === null || !this.menu.isOpen() ) {
this.$.fadeIn( 'fast' );
}
// Open the inspector by name.
inspector.open();
// Resize frame to the size of the inspector.
this.frame.setSize( inspector.$.outerWidth(), inspector.$.outerHeight() );
// Save name of inspector open.
this.inspector = name;
// Cache selection, in the case of manually opened inspector.
this.selection = this.surface.getModel().getSelection();
// Set inspector
this.show();
}, this ), 0 );
// Open the inspector
this.inspectors[name].open();
};
ve.ui.Context.prototype.closeInspector = function ( accept ) {
ve.ui.Context.prototype.closeInspector = function ( remove ) {
// Quietly ignore if nothing is open
if ( this.inspector ) {
this.obscure( this.$inspectors );
this.inspectors[this.inspector].close( accept );
this.inspector = null;
this.hide();
// Close the current inspector
this.inspectors[this.inspector].close( remove );
}
this.update();
};
ve.ui.Context.prototype.reveal = function ( $element ) {

View file

@ -19,8 +19,8 @@ ve.ui.Inspector = function VeUiInspector( context ) {
// Properties
this.context = context;
this.disabled = false;
this.initialSelection = null;
this.closing = false;
this.frame = context.getFrame();
this.$ = $( '<div class="ve-ui-inspector"></div>' );
this.$form = this.frame.$$( '<form></form>' );
@ -46,6 +46,11 @@ ve.ui.Inspector = function VeUiInspector( context ) {
'submit': ve.bind( this.onFormSubmit, this ),
'keydown': ve.bind( this.onFormKeyDown, this )
} );
this.addListenerMethods( this, {
'initialize': 'onInitialize',
'open': 'onOpen',
'close': 'onClose'
} );
// Initialization
this.$.append(
@ -71,31 +76,6 @@ ve.ui.Inspector.static.typePattern = new RegExp();
/* Methods */
/**
* Checks if this inspector is disabled.
*
* @method
* @param {Boolean} Inspector is disabled
*/
ve.ui.Inspector.prototype.isDisabled = function () {
return this.disabled;
};
/**
* Disables inspector.
*
* @method
* @param {Boolean} state Disable inspector
*/
ve.ui.Inspector.prototype.setDisabled = function ( state ) {
this.disabled = !!state;
if ( this.disabled ) {
this.$.addClass( 've-ui-inspector-disabled' );
} else {
this.$.removeClass( 've-ui-inspector-disabled' );
}
};
/**
* Responds to close button click events.
*
@ -103,7 +83,7 @@ ve.ui.Inspector.prototype.setDisabled = function ( state ) {
* @param {jQuery.Event} e Click event
*/
ve.ui.Inspector.prototype.onCloseButtonClick = function () {
this.context.closeInspector( true );
this.close();
};
/**
@ -114,14 +94,7 @@ ve.ui.Inspector.prototype.onCloseButtonClick = function () {
* @emits 'remove'
*/
ve.ui.Inspector.prototype.onRemoveButtonClick = function() {
if ( !this.disabled ) {
this.context.getSurface().execute(
'annotation', 'clearAll', this.constructor.static.typePattern
);
this.onRemove();
this.emit( 'remove' );
}
this.context.closeInspector();
this.close( true );
};
/**
@ -131,11 +104,8 @@ ve.ui.Inspector.prototype.onRemoveButtonClick = function() {
* @param {jQuery.Event} e Submit event
*/
ve.ui.Inspector.prototype.onFormSubmit = function ( e ) {
this.close();
e.preventDefault();
if ( this.$.hasClass( 've-ui-inspector-disabled' ) ) {
return;
}
this.context.closeInspector( true );
return false;
};
@ -148,12 +118,24 @@ ve.ui.Inspector.prototype.onFormSubmit = function ( e ) {
ve.ui.Inspector.prototype.onFormKeyDown = function ( e ) {
// Escape
if ( e.which === 27 ) {
this.context.closeInspector( true );
this.close();
e.preventDefault();
return false;
}
};
/**
* Responds to the inspector being initialized.
*
* This gives an inspector an opportunity to make selection and annotation changes prior to the
* inspector being opened.
*
* @method
*/
ve.ui.Inspector.prototype.onInitialize = function () {
// This is a stub, override functionality in child classes
};
/**
* Responds to the inspector being opened.
*
@ -186,18 +168,6 @@ ve.ui.Inspector.prototype.onRemove = function () {
// This is a stub, override functionality in child classes
};
/**
* Prepares the inspector to be opened.
*
* This gives an inspector an opportunity to make selection and annotation changes prior to the
* inspector being opened.
*
* @method
*/
ve.ui.Inspector.prototype.prepareSelection = function () {
// This is a stub, override functionality in child classes
};
/**
* Gets a list of matching annotations in selection.
*
@ -213,23 +183,32 @@ ve.ui.Inspector.prototype.getMatchingAnnotations = function ( fragment ) {
* Opens inspector.
*
* @method
* @emits 'initialize'
* @emits 'open'
*/
ve.ui.Inspector.prototype.open = function () {
this.$.show();
this.emit( 'initialize' );
this.initialSelection = this.context.getSurface().getModel().getSelection();
this.onOpen();
this.emit( 'open' );
};
/**
* Closes inspector.
*
* This method guards against recursive calling internally. Recursion on this method is caused by
* changes to the document occuring in a close handler which in turn produce document model change
* events, which in turn cause the context to close the inspector again, and so on.
*
* @method
* @emits 'close'
* @emits 'close' (remove)
*/
ve.ui.Inspector.prototype.close = function ( accept ) {
ve.ui.Inspector.prototype.close = function ( remove ) {
if ( !this.closing ) {
this.closing = true;
this.$.hide();
this.onClose( accept );
this.emit( 'close' );
this.emit( 'close', remove );
this.context.getSurface().getView().getDocument().getDocumentNode().$.focus();
this.closing = false;
}
};