File: ve.ui.MWTwoPaneTransclusionDialogLayout.js

package info (click to toggle)
mediawiki 1%3A1.43.3%2Bdfsg-1
  • links: PTS, VCS
  • area: main
  • in suites: trixie
  • size: 417,464 kB
  • sloc: php: 1,062,949; javascript: 664,290; sql: 9,714; python: 5,458; xml: 3,489; sh: 1,131; makefile: 64
file content (376 lines) | stat: -rw-r--r-- 12,199 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
/**
 * 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.<string,OO.ui.PageLayout>} 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(
			$( '<div>' ).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.<string,boolean>} 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
	} );
};