/** * Specialized layout similar to BookletLayout, but to synchronize the sidebar * and content pane of the transclusion dialog * * Also owns the outline controls. * * @class * @extends OO.ui.MenuLayout * * @constructor * @param {Object} [config] Configuration options * @cfg {boolean} [outlined=false] Show the outline. The outline is used to navigate through the * pages of the booklet. * @cfg {boolean} [editable=false] Show controls for adding, removing and reordering pages. */ ve.ui.MWTwoPaneTransclusionDialogLayout = function VeUiMWTwoPaneTransclusionDialogLayout( config ) { // Configuration initialization config = config || {}; // Parent constructor ve.ui.MWTwoPaneTransclusionDialogLayout.super.call( this, config ); // Properties this.currentPageName = null; this.pages = {}; this.ignoreFocus = false; this.stackLayout = new OO.ui.StackLayout( { continuous: true, expanded: this.expanded } ); this.setContentPanel( this.stackLayout ); this.sidebar = new ve.ui.MWTransclusionOutlineWidget(); this.autoFocus = true; this.outlineVisible = false; this.outlined = !!config.outlined; if ( this.outlined ) { this.editable = !!config.editable; this.outlineControlsWidget = null; this.outlineSelectWidget = new OO.ui.OutlineSelectWidget(); this.outlinePanel = new OO.ui.PanelLayout( { expanded: this.expanded, scrollable: true } ); this.setMenuPanel( this.outlinePanel ); this.outlineVisible = true; if ( this.editable ) { this.outlineControlsWidget = new OO.ui.OutlineControlsWidget( this.outlineSelectWidget ); } } this.toggleMenu( this.outlined ); // Events this.stackLayout.connect( this, { set: 'onStackLayoutSet' } ); if ( this.outlined ) { this.outlineSelectWidget.connect( this, { select: 'onOutlineSelectWidgetSelect' } ); } // Event 'focus' does not bubble, but 'focusin' does this.stackLayout.$element.on( 'focusin', this.onStackLayoutFocus.bind( this ) ); // Initialization this.$element.addClass( 've-ui-mwTwoPaneTransclusionDialogLayout' ); this.stackLayout.$element.addClass( 've-ui-mwTwoPaneTransclusionDialogLayout-stackLayout' ); if ( this.outlined ) { this.outlinePanel.$element .addClass( 've-ui-mwTwoPaneTransclusionDialogLayout-outlinePanel' ); if ( this.editable ) { this.outlinePanel.$element .addClass( 've-ui-mwTwoPaneTransclusionDialogLayout-outlinePanel-editable' ) .append( this.outlineControlsWidget.$element ); } } }; /* Setup */ OO.inheritClass( ve.ui.MWTwoPaneTransclusionDialogLayout, OO.ui.MenuLayout ); /* Events */ /** * A 'set' event is emitted when a page is {@link #setPage set} to be displayed by the * booklet layout. * * @event set * @param {OO.ui.PageLayout} page Current page */ /** * An 'add' event is emitted when pages are {@link #addPages added} to the booklet layout. * * @event add * @param {OO.ui.PageLayout[]} page Added pages * @param {number} index Index pages were added at */ /** * A 'remove' event is emitted when pages are {@link #clearPages cleared} or * {@link #removePages removed} from the booklet. * * @event remove * @param {OO.ui.PageLayout[]} pages Removed pages */ /* Methods */ /** * Handle stack layout focus. * * @private * @param {jQuery.Event} e Focusin event */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.onStackLayoutFocus = function ( e ) { var name, $target; // Find the page that an element was focused within $target = $( e.target ).closest( '.oo-ui-pageLayout' ); for ( name in this.pages ) { // Check for page match, exclude current page to find only page changes if ( this.pages[ name ].$element[ 0 ] === $target[ 0 ] && name !== this.currentPageName ) { this.setPage( name ); break; } } }; /** * Handle stack layout set events. * * @private * @param {OO.ui.PanelLayout|null} page The page panel that is now the current panel */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.onStackLayoutSet = function ( page ) { var promise, layout = this; // If everything is unselected, do nothing if ( !page ) { return; } // Scroll the selected page into view first if ( !this.scrolling ) { promise = page.scrollElementIntoView(); } else { // eslint-disable-next-line no-jquery/no-deferred promise = $.Deferred().resolve(); } // Focus the first element on the newly selected panel. // Don't focus if the page was set by scrolling. if ( this.autoFocus && !OO.ui.isMobile() && !this.scrolling ) { promise.done( function () { layout.focus(); } ); } }; /** * Focus the first input in the current page. * * If no page is selected, the first selectable page will be selected. * If the focus is already in an element on the current page, nothing will happen. * * @param {number} [itemIndex] A specific item to focus on */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.focus = function ( itemIndex ) { var page, items = this.stackLayout.getItems(); if ( itemIndex !== undefined && items[ itemIndex ] ) { page = items[ itemIndex ]; } else { page = this.stackLayout.getCurrentItem(); } if ( !page && this.outlined ) { this.selectFirstSelectablePage(); page = this.stackLayout.getCurrentItem(); } if ( !page ) { return; } // Only change the focus if is not already in the current page if ( !OO.ui.contains( page.$element[ 0 ], this.getElementDocument().activeElement, true ) ) { page.focus(); } }; /** * Handle outline widget select events. * * @private * @param {OO.ui.OptionWidget|null} item Selected item */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.onOutlineSelectWidgetSelect = function ( item ) { if ( item ) { this.setPage( item.getData() ); } }; /** * Check if booklet has an outline. * * @return {boolean} Booklet has an outline */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.isOutlined = function () { return this.outlined; }; /** * Check if booklet has editing controls. * * @return {boolean} Booklet is editable */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.isEditable = function () { return this.editable; }; /** * Check if booklet has a visible outline. * * @return {boolean} Outline is visible */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.isOutlineVisible = function () { return this.outlined && this.outlineVisible; }; /** * Hide or show the outline. * * @param {boolean} [show] Show outline, omit to invert current state * @chainable * @return {ve.ui.MWTwoPaneTransclusionDialogLayout} The layout, for chaining */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.toggleOutline = function ( show ) { var booklet = this; if ( this.outlined ) { show = show === undefined ? !this.outlineVisible : !!show; this.outlineVisible = show; this.toggleMenu( show ); if ( show && this.editable ) { // HACK: Kill dumb scrollbars when the sidebar stops animating, see T161798. // Only necessary when outline controls are present, delay matches transition on // `.oo-ui-menuLayout-menu`. setTimeout( function () { OO.ui.Element.static.reconsiderScrollbars( booklet.outlinePanel.$element[ 0 ] ); }, OO.ui.theme.getDialogTransitionDuration() ); } } return this; }; /** * Find the page closest to the specified page. * * @param {OO.ui.PageLayout} page Page to use as a reference point * @return {OO.ui.PageLayout|null} Page closest to the specified page */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.findClosestPage = function ( page ) { var next, prev, level, pages = this.stackLayout.getItems(), index = pages.indexOf( page ); if ( index !== -1 ) { next = pages[ index + 1 ]; prev = pages[ index - 1 ]; // Prefer adjacent pages at the same level if ( this.outlined ) { level = this.outlineSelectWidget.findItemFromData( page.getName() ).getLevel(); if ( prev && level === this.outlineSelectWidget.findItemFromData( prev.getName() ).getLevel() ) { return prev; } if ( next && level === this.outlineSelectWidget.findItemFromData( next.getName() ).getLevel() ) { return next; } } } return prev || next || null; }; /** * Get the outline widget. * * If the booklet is not outlined, the method will return `null`. * * @return {OO.ui.OutlineSelectWidget|null} Outline widget, or null if the booklet is not outlined */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.getOutline = function () { return this.outlineSelectWidget; }; /** * Get the outline controls widget. * * If the outline is not editable, the method will return `null`. * * @return {OO.ui.OutlineControlsWidget|null} The outline controls widget. */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.getOutlineControls = function () { return this.outlineControlsWidget; }; /** * Get a page by its symbolic name. * * @param {string} name Symbolic name of page * @return {OO.ui.PageLayout|undefined} Page, if found */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.getPage = function ( name ) { return this.pages[ name ]; }; /** * Get the current page. * * @return {OO.ui.PageLayout|undefined} Current page, if found */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.getCurrentPage = function () { var name = this.getCurrentPageName(); return name ? this.getPage( name ) : undefined; }; /** * Get the symbolic name of the current page. * * @return {string|null} Symbolic name of the current page */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.getCurrentPageName = function () { return this.currentPageName; }; /** * Add pages to the booklet layout * * When pages are added with the same names as existing pages, the existing pages will be * automatically removed before the new pages are added. * * @param {OO.ui.PageLayout[]} pages Pages to add * @param {number} index Index of the insertion point * @fires add * @chainable * @return {ve.ui.MWTwoPaneTransclusionDialogLayout} The layout, for chaining */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.addPages = function ( pages, index ) { var i, len, name, page, item, currentIndex, stackLayoutPages = this.stackLayout.getItems(), remove = [], items = []; // Remove pages with same names for ( i = 0, len = pages.length; i < len; i++ ) { page = pages[ i ]; name = page.getName(); if ( Object.prototype.hasOwnProperty.call( this.pages, name ) ) { // Correct the insertion index currentIndex = stackLayoutPages.indexOf( this.pages[ name ] ); if ( currentIndex !== -1 && currentIndex + 1 < index ) { index--; } remove.push( this.pages[ name ] ); } } if ( remove.length ) { this.removePages( remove ); } // Add new pages for ( i = 0, len = pages.length; i < len; i++ ) { page = pages[ i ]; name = page.getName(); this.pages[ page.getName() ] = page; if ( this.outlined ) { item = new OO.ui.OutlineOptionWidget( { data: name } ); page.setOutlineItem( item ); items.push( item ); } } if ( this.outlined ) { this.outlineSelectWidget.addItems( items, index ); // It's impossible to lose a selection here. Selecting something else is business logic. } this.stackLayout.addItems( pages, index ); this.emit( 'add', pages, index ); return this; }; /** * Remove the specified pages from the booklet layout. * * To remove all pages from the booklet, you may wish to use the #clearPages method instead. * * @param {OO.ui.PageLayout[]} pages An array of pages to remove * @fires remove * @chainable * @return {ve.ui.MWTwoPaneTransclusionDialogLayout} The layout, for chaining */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.removePages = function ( pages ) { var i, len, name, page, itemsToRemove = []; for ( i = 0, len = pages.length; i < len; i++ ) { page = pages[ i ]; name = page.getName(); delete this.pages[ name ]; if ( this.outlined ) { itemsToRemove.push( this.outlineSelectWidget.findItemFromData( name ) ); page.setOutlineItem( null ); } // If the current page is removed, clear currentPageName if ( this.currentPageName === name ) { this.currentPageName = null; } } if ( itemsToRemove.length ) { this.outlineSelectWidget.removeItems( itemsToRemove ); // We might loose the selection here, but what to select instead is business logic. } this.stackLayout.removeItems( pages ); this.emit( 'remove', pages ); return this; }; /** * Clear all pages from the booklet layout. * * To remove only a subset of pages from the booklet, use the #removePages method. * * @fires remove * @chainable * @return {ve.ui.MWTwoPaneTransclusionDialogLayout} The layout, for chaining */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.clearPages = function () { var i, len, pages = this.stackLayout.getItems(); this.pages = {}; this.currentPageName = null; if ( this.outlined ) { this.outlineSelectWidget.clearItems(); for ( i = 0, len = pages.length; i < len; i++ ) { pages[ i ].setOutlineItem( null ); } } this.stackLayout.clearItems(); this.emit( 'remove', pages ); return this; }; /** * Set the current page by symbolic name. * * @fires set * @param {string} name Symbolic name of page */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.setPage = function ( name ) { var page = this.pages[ name ]; if ( !page || name === this.currentPageName ) { return; } var previousPage = this.currentPageName ? this.pages[ this.currentPageName ] : null; this.currentPageName = name; if ( this.outlined ) { var selectedItem = this.outlineSelectWidget.findSelectedItem(); if ( !selectedItem || selectedItem.getData() !== name ) { // Warning! This triggers a "select" event and the .onOutlineSelectWidgetSelect() // handler, which calls .setPage() a second time. Make sure .currentPageName is set to // break this loop. this.outlineSelectWidget.selectItemByData( name ); } } var $focused; if ( previousPage ) { previousPage.setActive( false ); // Blur anything focused if the next page doesn't have anything focusable. // This is not needed if the next page has something focusable (because once it is // focused this blur happens automatically). if ( !OO.ui.isMobile() && OO.ui.findFocusable( page.$element ).length !== 0 ) { $focused = previousPage.$element.find( ':focus' ); if ( $focused.length ) { $focused[ 0 ].blur(); } } } page.setActive( true ); this.stackLayout.setItem( page ); this.emit( 'set', page ); }; /** * For outlined booklets, also reset the outlineSelectWidget to the first item. * * @inheritdoc */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.resetScroll = function () { // Parent method ve.ui.MWTwoPaneTransclusionDialogLayout.super.prototype.resetScroll.call( this ); if ( this.outlined && this.outlineSelectWidget.findFirstSelectableItem() ) { this.scrolling = true; this.outlineSelectWidget.selectItem( this.outlineSelectWidget.findFirstSelectableItem() ); this.scrolling = false; } return this; }; /** * Select the first selectable page. * * @chainable * @return {ve.ui.MWTwoPaneTransclusionDialogLayout} The layout, for chaining */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.selectFirstSelectablePage = function () { if ( !this.outlineSelectWidget.findSelectedItem() ) { this.outlineSelectWidget.selectItem( this.outlineSelectWidget.findFirstSelectableItem() ); } return this; };