/*! * VisualEditor UserInterface Toolbar class. * * @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt * @license The MIT License (MIT); see LICENSE.txt */ /** * UserInterface toolbar. * * @class * @extends ve.Element * @mixins ve.EventEmitter * * @constructor * @param {ve.ui.Surface} surface * @param {Object} [config] Config options * @cfg {boolean} [actions] Add an actions section opposite to the tools * @cfg {boolean} [shadow] Add a shadow below the toolbar */ ve.ui.Toolbar = function VeUiToolbar( surface, options ) { var toolbar = this; // Configuration initialization options = options || {}; // Parent constructor ve.Element.call( this, options ); // Mixin constructors ve.EventEmitter.call( this ); // Properties this.surface = surface; this.$bar = this.$$( '
' ); this.$tools = this.$$( '
' ); this.$actions = this.$$( '
' ); this.floating = false; this.floatable = false; this.initialized = false; this.$window = null; this.$surfaceView = null; this.elementOffset = null; this.windowEvents = { // jQuery puts a guid on our prototype function when we use ve.bind, // we don't want that because that means calling $window.off( toolbarB.windowEvents ) // will effectively also unbind toolbarA.windowEvents as they would share a guid. // Though jQuery does not share the reference (both A and B have the correct context // bound), it does unbind them. Use a regular closure instead. 'resize': function () { return toolbar.onWindowResize.apply( toolbar, arguments ); }, 'scroll': function () { return toolbar.onWindowScroll.apply( toolbar, arguments ); } }; this.surfaceViewEvents = { 'keyup': function () { return toolbar.onSurfaceViewKeyUp.apply( toolbar, arguments ); } }; // Events this.$ .add( this.$bar ).add( this.$tools ).add( this.$actions ) .on( 'mousedown', false ); // Initialization this.$tools.addClass( 've-ui-toolbar-tools' ); this.$bar.addClass( 've-ui-toolbar-bar' ).append( this.$tools ); if ( options.actions ) { this.$actions.addClass( 've-ui-toolbar-actions' ); this.$bar.append( this.$actions ); } this.$bar.append( '
' ); if ( options.shadow ) { this.$bar.append( '
' ); } this.$.addClass( 've-ui-toolbar' ).append( this.$bar ); // Events this.surface.getModel().connect( this, { 'contextChange': 'onContextChange' } ); }; /* Inheritance */ ve.inheritClass( ve.ui.Toolbar, ve.Element ); ve.mixinClass( ve.ui.Toolbar, ve.EventEmitter ); /* Events */ /** * @event updateState * @see ve.dm.SurfaceFragment#getAnnotations * @param {ve.dm.Node[]} nodes List of nodes covered by the current selection * @param {ve.dm.AnnotationSet} full Annotations that cover all of the current selection * @param {ve.dm.AnnotationSet} partial Annotations that cover some or all of the current selection */ /** * Whenever the toolbar $bar position is updated, the changes that took place. * * @event position * @param {jQuery} $bar Toolbar bar * @param {Object} update * @param {boolean} [update.floating] Whether the toolbar is in floating mode * @param {Object} [update.css] One or more css properties that changed * @param {Object} [update.offset] Updated offset object (from jQuery.fn.offset, though * it also includes `offset.right`) */ /* Methods */ /** * Sets up handles and preloads required information for the toolbar to work. * This must be called immediately after it is attached to a visible document. */ ve.ui.Toolbar.prototype.initialize = function () { this.initialized = true; this.$window = $( this.getElementWindow() ); this.$surfaceView = this.surface.getView().$; this.elementOffset = this.$.offset(); this.elementOffset.right = this.$window.width() - this.$.outerWidth() - this.elementOffset.left; // Initial position. Could be invalidated by the first // call to onWindowScroll, but users of this event (e.g toolbarTracking) // need to also now the non-floating position. this.emit( 'position', this.$bar, { 'floating': false, 'offset': this.elementOffset } ); if ( this.floatable ) { this.$window.on( this.windowEvents ); this.$surfaceView.on( this.surfaceViewEvents ); // The page may start with a non-zero scroll position this.onWindowScroll(); } }; /** * Handle window resize events while toolbar floating is enabled. * * @returns {jQuery.Event} e Window resize event */ ve.ui.Toolbar.prototype.onWindowScroll = function () { var scrollTop = this.$window.scrollTop(); if ( scrollTop > this.elementOffset.top ) { this.float(); } else if ( this.floating ) { this.unfloat(); } }; /** * Handle window resize events while toolbar floating is enabled. * * Toolbar will stick to the top of the screen unless it would be over or under the last visible * branch node in the root of the document being edited, at which point it will stop just above it. * * @emits position * @returns {jQuery.Event} e Window scroll event */ ve.ui.Toolbar.prototype.onWindowResize = function () { var update = {}, offset = this.elementOffset; // Update right offset after resize (see #float) offset.right = this.$window.width() - this.$.outerWidth() - offset.left; update.offset = offset; if ( this.floating ) { update.css = { 'right': offset.right }; this.$bar.css( update.css ); } // If we're not floating, toolbar position didn't change. // But the dimensions did naturally change on resize, as did the right offset. // Which e.g. mw.ViewPageTarget's toolbarTracker needs. this.emit( 'position', this.$bar, update ); }; /** * Method to scroll to the cursor position while toolbar is floating on keyup only if * the cursor is obscured by the toolbar. */ ve.ui.Toolbar.prototype.onSurfaceViewKeyUp = function () { var cursorPos = this.surface.view.getSelectionRect(), barHeight = this.$bar.height(), scrollTo = this.$bar.offset().top - barHeight + ( cursorPos.end.y - cursorPos.start.y ), obscured = cursorPos.start.y - this.$window.scrollTop() < barHeight; // If toolbar is floating and cursor is obscured, scroll cursor into view if ( obscured && this.floating ) { $( 'html, body' ).animate( { scrollTop: scrollTo }, 0 ); } }; /** * Gets the surface the toolbar controls. * * @returns {ve.ui.Surface} Surface being controlled */ ve.ui.Toolbar.prototype.getSurface = function () { return this.surface; }; /** * Handle context changes on the surface. * * @emits updateState */ ve.ui.Toolbar.prototype.onContextChange = function () { var i, len, leafNodes, fragment = this.surface.getModel().getFragment( null, false ), nodes = []; leafNodes = fragment.getLeafNodes(); for ( i = 0, len = leafNodes.length; i < len; i++ ) { if ( len === 1 || !leafNodes[i].range || leafNodes[i].range.getLength() ) { nodes.push( leafNodes[i].node ); } } this.emit( 'updateState', nodes, fragment.getAnnotations(), fragment.getAnnotations( true ) ); }; /** * Initialize all tools and groups. */ ve.ui.Toolbar.prototype.addTools = function ( tools ) { var i, j, group, $group, tool; for ( i = 0; i < tools.length; i++ ) { group = tools[i]; // Create group $group = this.$$( '
' ) .on( 'mousedown', false ); if ( group.label ) { $group.append( this.$$( '
' ).html( group.label ) ); } // Add tools for ( j = 0; j < group.items.length; j++ ) { tool = false; try { tool = ve.ui.toolFactory.create( group.items[j], this ); } catch( e ) {} if ( tool ) { $group.append( tool.$ ); } } // Append group this.$tools.append( $group ); } }; /** * Destroys toolbar, removing event handlers and DOM elements. * * Call this whenever you are done using a toolbar. */ ve.ui.Toolbar.prototype.destroy = function () { this.disableFloatable(); this.surface.getModel().disconnect( this, { 'contextChange': 'onContextChange' } ); this.$.remove(); }; /** * Float the toolbar. * * @emits position */ ve.ui.Toolbar.prototype.float = function () { var update; if ( !this.floating ) { // When switching into floating mode, set the height of the wrapper and // move the bar to the same offset as the in-flow element update = { 'css': { 'left': this.elementOffset.left, 'right': this.elementOffset.right }, 'floating': true }; this.$ .css( 'height', this.$.height() ) .addClass( 've-ui-toolbar-floating' ); this.$bar.css( update.css ); this.floating = true; this.emit( 'position', this.$bar, update ); } }; /** * Reset the toolbar to it's default non-floating position. * * @emits position */ ve.ui.Toolbar.prototype.unfloat = function () { if ( this.floating ) { this.$ .css( 'height', '' ) .removeClass( 've-ui-toolbar-floating' ); this.$bar.css( { 'left': '', 'right': '' } ); this.floating = false; this.emit( 'position', this.$bar, { 'floating': false } ); } }; /** * Set automatic floating behavior to the toolbar. * * Toolbar floating is not enabled by default, call this on setup to enable it. * This will not make it float, but it will start listening for events that * will result in it potentially being floated and defloated accordingly. */ ve.ui.Toolbar.prototype.enableFloatable = function () { this.floatable = true; if ( this.initialized ) { this.$window.on( this.windowEvents ); this.$surfaceView.on( this.surfaceViewEvents ); } }; /** * Remove automatic floating behavior to the toolbar. */ ve.ui.Toolbar.prototype.disableFloatable = function () { if ( this.$window ) { this.$window.off( this.windowEvents ); } if ( this.$surfaceView ) { this.$surfaceView.off( this.surfaceViewEvents ); } if ( this.floating ) { this.unfloat(); } this.floatable = false; };