/** * Specialized layout forked from and similar to {@see OO.ui.BookletLayout}, but to synchronize the * sidebar and content pane of the transclusion dialog. * * Also owns the outline controls. * * This class has domain knowledge about its contents, for example different * behaviors for template vs template parameter elements. * * @class * @extends OO.ui.MenuLayout * * @constructor * @param {Object} [config] Configuration options * @property {Object.} pages * @property {string} currentPageName Name of the currently selected transclusion item (top-level * part or template parameter). Typically represented as a blue bar in the sidebar. Special cases * you should be aware of: * - An unchecked parameter exists as an item in the sidebar, but not as a page in the content * pane. * - A parameter placeholder (to add an undocumented parameter) exists as a page in the content * pane, but has no corresponding item in the sidebar. */ ve.ui.MWTwoPaneTransclusionDialogLayout = function VeUiMWTwoPaneTransclusionDialogLayout( config ) { // Parent constructor ve.ui.MWTwoPaneTransclusionDialogLayout.super.call( this, config ); // Properties this.pages = {}; this.currentPageName = null; this.stackLayout = new ve.ui.MWVerticalLayout(); this.setContentPanel( this.stackLayout ); this.sidebar = new ve.ui.MWTransclusionOutlineWidget(); this.outlinePanel = new OO.ui.PanelLayout( { expanded: this.expanded, scrollable: true } ); this.setMenuPanel( this.outlinePanel ); this.outlineControlsWidget = new ve.ui.MWTransclusionOutlineControlsWidget(); // Events this.sidebar.connect( this, { filterPagesByName: 'onFilterPagesByName', sidebarItemSelected: 'onSidebarItemSelected' } ); // 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' ); this.outlinePanel.$element .addClass( 've-ui-mwTwoPaneTransclusionDialogLayout-outlinePanel' ) .append( $( '
' ).addClass( 've-ui-mwTwoPaneTransclusionDialogLayout-sidebar-container' ) .append( this.sidebar.$element ), this.outlineControlsWidget.$element ); }; /* Setup */ OO.inheritClass( ve.ui.MWTwoPaneTransclusionDialogLayout, OO.ui.MenuLayout ); /* Methods */ /** * @private * @param {Object.} visibility */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.onFilterPagesByName = function ( visibility ) { this.currentPageName = null; for ( const pageName in visibility ) { const page = this.getPage( pageName ); if ( page ) { page.toggle( visibility[ pageName ] ); } } }; /** * @param {ve.dm.MWTransclusionPartModel|null} removed Removed part * @param {ve.dm.MWTransclusionPartModel|null} added Added part * @param {number} [newPosition] */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.onReplacePart = function ( removed, added, newPosition ) { this.sidebar.onReplacePart( removed, added, newPosition ); const keys = Object.keys( this.pages ), isMultiPart = keys.length > 1, isLastPlaceholder = keys.length === 1 && this.pages[ keys[ 0 ] ] instanceof ve.ui.MWTemplatePlaceholderPage; // TODO: In other cases this is disabled rather than hidden. See T311303 this.outlineControlsWidget.removeButton.toggle( !isLastPlaceholder ); if ( isMultiPart ) { // Warning, this is intentionally never turned off again this.outlineControlsWidget.toggle( true ); } }; /** * @private * @param {jQuery.Event} e Focusin event */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.onStackLayoutFocus = function ( e ) { // Find the page that an element was focused within const $target = $( e.target ).closest( '.oo-ui-pageLayout' ); for ( const name in this.pages ) { if ( this.pages[ name ].$element[ 0 ] === $target[ 0 ] ) { this.setPage( name ); break; } } }; /** * Focus the input field for the current page. * * If the focus is already in an element on the current page, nothing will happen. */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.focus = function () { const page = this.pages[ this.currentPageName ]; if ( !page ) { return; } // Only change the focus if it's visible and is not already the current page if ( page.$element[ 0 ].offsetParent !== null && !OO.ui.contains( page.$element[ 0 ], this.getElementDocument().activeElement, true ) ) { page.focus(); } }; /** * @param {string} pageName */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.focusPart = function ( pageName ) { this.setPage( pageName ); this.focus(); }; /** * Parts and parameters can be soft-selected, or selected and focused. * * @param {string|null} pageName Full, unique name of part or parameter, or null to deselect * @param {boolean} [soft] If true, suppress content pane focus. */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.onSidebarItemSelected = function ( pageName, soft ) { this.setPage( pageName ); const page = this.pages[ pageName ]; if ( page ) { // Warning, scrolling must be done before focussing. The browser will trigger a conflicting // scroll when the focussed element is out of view. page.scrollElementIntoView( { alignToTop: true, padding: { top: 20 } } ); } // We assume "mobile" means "touch device with on-screen keyboard". That should only open when // tapping the input field, not when navigating in the sidebar. if ( !soft && !OO.ui.isMobile() ) { this.focus(); } }; /** * @param {boolean} show If the sidebar should be shown or not. */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.toggleOutline = function ( show ) { this.toggleMenu( show ); if ( show ) { // 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( () => { OO.ui.Element.static.reconsiderScrollbars( this.outlinePanel.$element[ 0 ] ); }, OO.ui.theme.getDialogTransitionDuration() ); } }; /** * @return {ve.ui.MWTransclusionOutlineControlsWidget} */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.getOutlineControls = function () { return this.outlineControlsWidget; }; /** * Get the list of pages on the stack ordered by appearance. * * @return {OO.ui.PageLayout[]} */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.getPagesOrdered = function () { return this.stackLayout.getItems(); }; /** * @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 ]; }; /** * @return {OO.ui.PageLayout|undefined} Current page, if found */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.getCurrentPage = function () { return this.pages[ this.currentPageName ]; }; /** * @return {string|null} A top-level part id like "part_0" if that part is selected. When a * parameter is selected null is returned. */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.getSelectedTopLevelPartId = function () { const page = this.getCurrentPage(), isParameter = page instanceof ve.ui.MWParameterPage || page instanceof ve.ui.MWAddParameterPage; return page && !isParameter ? page.getName() : null; }; /** * @return {string|null} A top-level part id like "part_0" that corresponds to the current * selection, whatever is selected. When a parameter is selected the id of the template the * parameter belongs to is returned. */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.getTopLevelPartIdForSelection = function () { return this.currentPageName ? this.currentPageName.split( '/', 1 )[ 0 ] : null; }; /** * 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 */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.addPages = function ( pages, index ) { const stackLayoutPages = this.stackLayout.getItems(); // Remove pages with same names const remove = []; for ( let i = 0; i < pages.length; i++ ) { const page = pages[ i ]; const name = page.getName(); if ( Object.prototype.hasOwnProperty.call( this.pages, name ) ) { // Correct the insertion index const currentIndex = stackLayoutPages.indexOf( this.pages[ name ] ); if ( currentIndex !== -1 && currentIndex + 1 < index ) { index--; } remove.push( name ); } } if ( remove.length ) { this.removePages( remove ); } // Add new pages for ( let i = 0; i < pages.length; i++ ) { const page = pages[ i ]; const name = page.getName(); this.pages[ name ] = page; } this.stackLayout.addItems( pages, index ); }; /** * @param {string[]} pagesNamesToRemove */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.removePages = function ( pagesNamesToRemove ) { const pagesToRemove = [], isCurrentParameter = this.pages[ this.currentPageName ] instanceof ve.ui.MWParameterPage; let isCurrentPageRemoved = false, prevSelectionCandidate, nextSelectionCandidate; this.stackLayout.getItems().forEach( ( page ) => { const pageName = page.getName(); if ( pagesNamesToRemove.indexOf( pageName ) !== -1 ) { pagesToRemove.push( page ); delete this.pages[ pageName ]; if ( this.currentPageName === pageName ) { this.currentPageName = null; isCurrentPageRemoved = true; } return; } // Move the selection from a removed top-level part to another, but not to a parameter if ( pageName.indexOf( '/' ) === -1 ) { if ( !isCurrentPageRemoved ) { // The last part before the removed one prevSelectionCandidate = pageName; } else if ( !nextSelectionCandidate ) { // The first part after the removed one nextSelectionCandidate = pageName; } } } ); this.stackLayout.removeItems( pagesToRemove ); if ( isCurrentPageRemoved && !isCurrentParameter ) { this.setPage( nextSelectionCandidate || prevSelectionCandidate ); } }; ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.clearPages = function () { this.pages = {}; this.currentPageName = null; this.sidebar.clear(); this.stackLayout.clearItems(); }; /** * Set the current page and sidebar selection, by symbolic name. Doesn't focus the input. * * @param {string} [name] Symbolic name of page. Omit to remove current selection. */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.setPage = function ( name ) { const page = this.pages[ name ]; if ( page && name === this.currentPageName ) { return; } const previousPage = this.currentPageName ? this.pages[ this.currentPageName ] : null; this.currentPageName = name; if ( previousPage ) { // 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() && ( !page || OO.ui.findFocusable( page.$element ).length !== 0 ) ) { const $focused = previousPage.$element.find( ':focus' ); if ( $focused.length ) { $focused[ 0 ].blur(); } } } this.sidebar.setSelectionByPageName( name ); this.refreshControls(); }; /** * @private */ ve.ui.MWTwoPaneTransclusionDialogLayout.prototype.refreshControls = function () { const partId = this.getSelectedTopLevelPartId(), canBeDeleted = !!partId; let canMoveUp, canMoveDown = false; if ( partId ) { const pages = this.stackLayout.getItems(), page = this.getPage( partId ), index = pages.indexOf( page ); canMoveUp = index > 0; // Check if there is at least one more top-level part below the current one for ( let i = index + 1; i < pages.length; i++ ) { if ( !( pages[ i ] instanceof ve.ui.MWParameterPage || pages[ i ] instanceof ve.ui.MWAddParameterPage ) ) { canMoveDown = true; break; } } } this.outlineControlsWidget.setButtonsEnabled( { canMoveUp: canMoveUp, canMoveDown: canMoveDown, canBeDeleted: canBeDeleted } ); };