mediawiki-extensions-Visual.../modules/ve/ui/ve.ui.Context.js
Roan Kattouw 3f5486a43f Use a sane recursion guard in ve.ui.Context.prototype.hide()
The previous recursion "guard" relied on the recursion to occur for
the function to work correctly, which was very confusing. Additionally,
it didn't actually work: if the inspector's onClose handler changed
the selection (which is common), .hide() would recurse and the recursion
guard didn't catch this (inspector wasn't undefined yet). Fortunately
all that did was call .close() on the inspector again, which was caught
by .close()'s own recursion guard.

Instead, simply use this.hiding as a recursion guard, and refactor the
function so it doesn't do two different things depending on whether it's
expecting recursion or not.

Change-Id: I09eb9229530468ee319ca53b730784a3f632375d
2013-10-21 14:10:23 +02:00

414 lines
10 KiB
JavaScript

/*!
* VisualEditor UserInterface Context class.
*
* @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
* @license The MIT License (MIT); see LICENSE.txt
*/
/**
* UserInterface context.
*
* @class
* @extends ve.Element
*
* @constructor
* @param {ve.ui.Surface} surface
* @param {Object} [config] Configuration options
*/
ve.ui.Context = function VeUiContext( surface, config ) {
// Parent constructor
ve.Element.call( this, config );
// Properties
this.surface = surface;
this.inspectors = {};
this.visible = false;
this.showing = false;
this.hiding = false;
this.selecting = false;
this.relocating = false;
this.embedded = false;
this.selection = null;
this.toolbar = null;
this.popup = new ve.ui.PopupWidget( { '$$': this.$$, '$container': this.surface.getView().$ } );
this.$menu = this.$$( '<div>' );
this.inspectors = new ve.ui.SurfaceWindowSet( surface, ve.ui.inspectorFactory );
// Initialization
this.$.addClass( 've-ui-context' ).append( this.popup.$ );
this.inspectors.$.addClass( 've-ui-context-inspectors' );
this.popup.$body.append(
this.$menu.addClass( 've-ui-context-menu' ),
this.inspectors.$.addClass( 've-ui-context-inspectors' )
);
// Events
this.surface.getModel().connect( this, { 'change': 'onChange' } );
this.surface.getView().connect( this, {
'selectionStart': 'onSelectionStart',
'selectionEnd': 'onSelectionEnd',
'relocationStart': 'onRelocationStart',
'relocationEnd': 'onRelocationEnd'
} );
this.inspectors.connect( this, {
'setup': 'onInspectorSetup',
'open': 'onInspectorOpen',
'close': 'onInspectorClose'
} );
this.$$( this.getElementWindow() ).on( {
'resize': ve.bind( this.update, this )
} );
this.$.add( this.$menu )
.on( 'mousedown', false );
};
/* Inheritance */
OO.inheritClass( ve.ui.Context, ve.Element );
/* Methods */
/**
* Handle change events on the model.
*
* Changes are ignored while the user is selecting text.
*
* @method
* @param {ve.dm.Transaction[]} transactions Change transactions
* @param {ve.Range} selection Change selection
*/
ve.ui.Context.prototype.onChange = function ( transactions, selection ) {
if ( selection ) {
if ( this.popup.isVisible() ) {
this.hide();
this.update();
} else if ( !this.selecting && !this.relocating ) {
this.update();
}
}
};
/**
* Handle selection start events on the view.
*
* @method
*/
ve.ui.Context.prototype.onSelectionStart = function () {
this.selecting = true;
this.hide();
};
/**
* Handle selection end events on the view.
*
* @method
*/
ve.ui.Context.prototype.onSelectionEnd = function () {
this.selecting = false;
if ( !this.relocating ) {
this.update();
}
};
/**
* Handle selection start events on the view.
*
* @method
*/
ve.ui.Context.prototype.onRelocationStart = function () {
this.relocating = true;
this.hide();
};
/**
* Handle selection end events on the view.
*
* @method
*/
ve.ui.Context.prototype.onRelocationEnd = function () {
this.relocating = false;
this.update();
};
/**
* Handle an inspector being setup.
*
* @method
* @param {ve.ui.SurfaceInspector} inspector Inspector that's been setup
*/
ve.ui.Context.prototype.onInspectorSetup = function () {
this.selection = this.surface.getModel().getSelection();
};
/**
* Handle an inspector being opened.
*
* @method
* @param {ve.ui.SurfaceInspector} inspector Inspector that's been opened
*/
ve.ui.Context.prototype.onInspectorOpen = function () {
// Transition between menu and inspector
this.show( true );
};
/**
* Handle an inspector being closed.
*
* @method
* @param {ve.ui.SurfaceInspector} inspector Inspector that's been opened
* @param {boolean} accept Changes have been accepted
*/
ve.ui.Context.prototype.onInspectorClose = function () {
this.update();
};
/**
* Gets the surface the context is being used in.
*
* @method
* @returns {ve.ui.Surface} Surface of context
*/
ve.ui.Context.prototype.getSurface = function () {
return this.surface;
};
/**
* Destroy the context, removing all DOM elements.
*
* @method
* @returns {ve.ui.Context} Context UserInterface
* @chainable
*/
ve.ui.Context.prototype.destroy = function () {
this.$.remove();
return this;
};
/**
* Updates the context menu.
*
* @method
* @chainable
*/
ve.ui.Context.prototype.update = function () {
var i, nodes, tools, tool,
fragment = this.surface.getModel().getFragment( null, false ),
selection = fragment.getRange(),
inspector = this.inspectors.getCurrent();
if ( inspector && selection.equals( this.selection ) ) {
// There's an inspector, and the selection hasn't changed, update the position
this.show();
} else {
// No inspector is open, or the selection has changed, show a menu of available inspectors
tools = ve.ui.toolFactory.getToolsForAnnotations( fragment.getAnnotations() );
nodes = fragment.getCoveredNodes();
for ( i = 0; i < nodes.length; i++ ) {
if ( nodes[i].range && nodes[i].range.isCollapsed() ) {
nodes.splice( i, 1 );
i--;
}
}
if ( nodes.length === 1 ) {
tool = ve.ui.toolFactory.getToolForNode( nodes[0].node );
if ( tool ) {
tools.push( tool );
}
}
if ( tools.length ) {
// There's at least one inspectable annotation, build a menu and show it
this.$menu.empty();
if ( this.toolbar ) {
this.toolbar.destroy();
}
this.toolbar = new ve.ui.SurfaceToolbar( this.surface );
this.toolbar.setup( [ { 'include' : tools } ] );
this.$menu.append( this.toolbar.$ );
this.show();
this.toolbar.initialize();
} else if ( this.visible ) {
// Nothing to inspect
this.hide();
}
}
// Remember selection for next time
this.selection = selection.clone();
return this;
};
/**
* Updates the position and size.
*
* @method
* @param {boolean} [transition=false] Use a smooth transition
* @chainable
*/
ve.ui.Context.prototype.updateDimensions = function ( transition ) {
var $node, $container, focusableOffset, focusableWidth, nodePosition, cursorPosition, position,
documentOffset, nodeOffset,
surface = this.surface.getView(),
inspector = this.inspectors.getCurrent(),
focusedNode = surface.getFocusedNode(),
surfaceOffset = surface.$.offset(),
rtl = this.surface.view.getDir() === 'rtl';
$container = inspector ? this.inspectors.$ : this.$menu;
if ( focusedNode ) {
// We're on top of a node
$node = focusedNode.$focusable || focusedNode.$;
if ( this.embedded ) {
// Get the position relative to the surface it is embedded in
focusableOffset = ve.Element.getRelativePosition(
$node, this.surface.$
);
position = { 'y': focusableOffset.top };
// When context is embedded in RTL, it requires adjustments to the relative
// positioning (pop up on the other side):
if ( rtl ) {
position.x = focusableOffset.left;
this.popup.align = 'left';
} else {
focusableWidth = $node.outerWidth();
position.x = focusableOffset.left + focusableWidth;
this.popup.align = 'right';
}
} else {
// The focused node may be in a wrapper, so calculate the offset relative to the document
documentOffset = surface.getDocument().documentNode.$.offset();
nodeOffset = $node.offset();
nodePosition = {
top: nodeOffset.top - documentOffset.top,
left: nodeOffset.left - documentOffset.left
};
// Get the position of the focusedNode:
position = { 'x': nodePosition.left, 'y': nodePosition.top + $node.outerHeight() };
// When the context is displayed in LTR, it should be on the right of the node
if ( !rtl ) {
position.x += $node.outerWidth();
}
this.popup.align = 'center';
}
} else {
// We're on top of a selected text
// Get the position of the cursor
cursorPosition = surface.getSelectionRect();
if ( !cursorPosition ) {
// The surface apparently isn't selected, so getSelectionRect() returned null.
// This shouldn't happen because the context is only supposed to be displayed in
// response to a selection, but for some reason this does happen in phantomjs.
// We can't update the context position if we don't know where the selection is,
// so just bail.
return this;
}
// Correct for surface offset:
position = {
'x': cursorPosition.end.x - surfaceOffset.left,
'y': cursorPosition.end.y - surfaceOffset.top
};
this.popup.align = 'center';
}
this.$.css( { 'left': position.x, 'top': position.y } );
this.popup.display(
$container.outerWidth( true ),
$container.outerHeight( true ),
transition
);
return this;
};
/**
* Shows the context menu.
*
* @method
* @param {boolean} [transition=false] Use a smooth transition
* @param {boolean} [repositionOnly=false] The context is only being moved so don't fade in
* @chainable
*/
ve.ui.Context.prototype.show = function ( transition, repositionOnly ) {
var inspector = this.inspectors.getCurrent(),
focusedNode = this.surface.getView().getFocusedNode();
if ( !this.showing ) {
this.showing = true;
this.$.show();
this.popup.show();
// Show either inspector or menu
if ( inspector ) {
this.$menu.hide();
this.inspectors.$.show();
if ( !repositionOnly ) {
inspector.$.css( 'opacity', 0 );
}
// Update size and fade the inspector in after animation is complete
setTimeout( ve.bind( function () {
inspector.fitHeightToContents();
this.updateDimensions( transition );
inspector.$.css( 'opacity', 1 );
}, this ), 200 );
} else {
this.inspectors.$.hide();
this.embedded = (
focusedNode &&
focusedNode.$focusable.outerHeight() > this.$menu.outerHeight() * 2 &&
focusedNode.$focusable.outerWidth() > this.$menu.outerWidth() * 2
);
this.popup.useTail( !this.embedded );
this.$menu.show();
}
this.updateDimensions( transition );
this.visible = true;
this.showing = false;
}
return this;
};
/**
* Hides the context menu.
*
* @method
* @chainable
*/
ve.ui.Context.prototype.hide = function () {
var inspector = this.inspectors.getCurrent();
// Guard against recursion: closing the inspector causes onInspectorClose to call us again
if ( !this.hiding ) {
this.hiding = true;
if ( inspector ) {
inspector.close( 'hide' );
}
this.popup.hide();
this.$.hide();
this.visible = false;
this.hiding = false;
}
return this;
};
/**
* Opens a given inspector.
*
* @method
* @param {string} name Symbolic name of inspector
* @param {Object} [config] Configuration options to be sent to the inspector class constructor
* @chainable
*/
ve.ui.Context.prototype.openInspector = function ( name, config ) {
if ( !this.inspectors.currentWindow ) {
this.inspectors.open( name, config );
}
return this;
};