File: ve.init.mw.MobileArticleTarget.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 (511 lines) | stat: -rw-r--r-- 16,314 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
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
/*!
 * VisualEditor MediaWiki Initialization MobileArticleTarget class.
 *
 * @copyright See AUTHORS.txt
 * @license The MIT License (MIT); see LICENSE.txt
 */

/**
 * @external mw.mobileFrontend.VisualEditorOverlay
 */

/**
 * MediaWiki mobile article target.
 *
 * @class
 * @extends ve.init.mw.ArticleTarget
 *
 * @constructor
 * @param {mw.mobileFrontend.VisualEditorOverlay} overlay Mobile frontend overlay
 * @param {Object} [config] Configuration options
 * @param {Object} [config.toolbarConfig]
 * @param {string|null} [config.section] Number of the section target should scroll to
 */
ve.init.mw.MobileArticleTarget = function VeInitMwMobileArticleTarget( overlay, config ) {
	this.overlay = overlay;
	this.$overlay = overlay.$el;
	this.$overlaySurface = overlay.$el.find( '.surface' );

	config = config || {};
	config.toolbarConfig = ve.extendObject( {
		actions: false
	}, config.toolbarConfig );

	// Parent constructor
	ve.init.mw.MobileArticleTarget.super.call( this, config );

	// eslint-disable-next-line no-jquery/no-global-selector
	this.$editableContent = $( '#mw-content-text' );

	if ( config.section !== undefined ) {
		this.section = config.section;
	}

	// Initialization
	this.$element.addClass( 've-init-mw-mobileArticleTarget ve-init-mobileTarget' );
};

/* Inheritance */

OO.inheritClass( ve.init.mw.MobileArticleTarget, ve.init.mw.ArticleTarget );

/* Static Properties */

ve.init.mw.MobileArticleTarget.static.toolbarGroups = [
	// History
	{
		name: 'history',
		include: [ 'undo' ]
	},
	// Style
	{
		name: 'style',
		classes: [ 've-test-toolbar-style' ],
		type: 'list',
		icon: 'textStyle',
		title: OO.ui.deferMsg( 'visualeditor-toolbar-style-tooltip' ),
		label: OO.ui.deferMsg( 'visualeditor-toolbar-style-tooltip' ),
		invisibleLabel: true,
		include: [ { group: 'textStyle' }, 'language', 'clear' ],
		forceExpand: [ 'bold', 'italic', 'clear' ],
		promote: [ 'bold', 'italic' ],
		demote: [ 'strikethrough', 'code', 'underline', 'language', 'clear' ]
	},
	// Link
	{
		name: 'link',
		include: [ 'link' ]
	},
	// Placeholder for reference tools (e.g. Cite and/or Citoid)
	{
		name: 'reference'
	}
];

ve.init.mw.MobileArticleTarget.static.trackingName = 'mobile';

// FIXME Some of these users will be on tablets, check for this
ve.init.mw.MobileArticleTarget.static.platformType = 'phone';

/* Methods */

/**
 * @inheritdoc
 */
ve.init.mw.MobileArticleTarget.prototype.deactivateSurfaceForToolbar = function () {
	// Parent method
	ve.init.mw.MobileArticleTarget.super.prototype.deactivateSurfaceForToolbar.call( this );

	if ( this.wasSurfaceActive && ve.init.platform.constructor.static.isIos() ) {
		this.prevScrollPosition = this.$scrollContainer.scrollTop();
	}
};

/**
 * @inheritdoc
 */
ve.init.mw.MobileArticleTarget.prototype.activateSurfaceForToolbar = function () {
	// Parent method
	ve.init.mw.MobileArticleTarget.super.prototype.activateSurfaceForToolbar.call( this );

	if ( this.wasSurfaceActive && ve.init.platform.constructor.static.isIos() ) {
		// Setting the cursor can cause unwanted scrolling on iOS, so manually
		// restore the scroll offset from before the toolbar was opened (T218650).
		this.$scrollContainer.scrollTop( this.prevScrollPosition );
	}
};

/**
 * @inheritdoc
 */
ve.init.mw.MobileArticleTarget.prototype.clearSurfaces = function () {
	if ( ve.init.platform.constructor.static.isIos() && this.viewportZoomHandler ) {
		this.viewportZoomHandler.detach();
		this.viewportZoomHandler = null;
	}
	// Parent method
	ve.init.mw.MobileArticleTarget.super.prototype.clearSurfaces.call( this );
};

/**
 * @inheritdoc
 */
ve.init.mw.MobileArticleTarget.prototype.onContainerScroll = function () {
	// Editor may not have loaded yet, in which case `this.surface` is undefined
	const surfaceView = this.surface && this.surface.getView(),
		isActiveWithKeyboard = surfaceView && surfaceView.isFocused() && !surfaceView.isDeactivated();

	// On iOS Safari, when the keyboard is open, the layout viewport reported by the browser is not
	// updated to match the real viewport reduced by the keyboard (diagram: T218414#5027607). On all
	// modern non-iOS browsers the layout viewport is updated to match real viewport.
	//
	// This allows the fixed toolbar to be scrolled out of view, ignoring `position: fixed` (because
	// it refers to the layout viewport).
	//
	// When this happens, bring it back in by scrolling down a bit and back up until the top of the
	// fake viewport is aligned with the top of the real viewport.

	clearTimeout( this.onContainerScrollTimer );
	if ( !isActiveWithKeyboard ) {
		return;
	}

	// Wait until after the scroll, because 'scroll' events are not emitted for every frame the
	// browser paints, so the toolbar would lag behind in a very unseemly manner. Additionally,
	// getBoundingClientRect returns incorrect values during scrolling, so make sure to calculate
	// it only after the scrolling ends (https://openradar.appspot.com/radar?id=6668472289329152).
	let animateToolbarIntoView;
	this.onContainerScrollTimer = setTimeout( animateToolbarIntoView = () => {
		if ( this.toolbarAnimating ) {
			// We can't do this while the 'transform' transition is happening, because
			// getBoundingClientRect() returns values that reflect that (and are negative).
			return;
		}

		const $header = this.overlay.$el.find( '.overlay-header-container' );

		// Check if toolbar is offscreen. In a better world, this would reject all negative values
		// (pos >= 0), but getBoundingClientRect often returns funny small fractional values after
		// this function has done its job (which triggers another 'scroll' event) and before the
		// user scrolled again. If we allowed it to run, it would trigger a hilarious loop! Toolbar
		// being 1px offscreen is not a big deal anyway.
		const pos = $header[ 0 ].getBoundingClientRect().top;
		if ( pos >= -1 ) {
			return;
		}

		// We don't know how much we have to scroll because we don't know how large the real
		// viewport is. This value is bigger than the screen height of all iOS devices.
		const viewportHeight = 2000;
		// OK so this one is really weird. Normally on iOS, the scroll position is set on <body>.
		// But on our sites, when using iOS 13, it's on <html> instead - maybe due to some funny
		// CSS we set on html and body? Anyway, this seems to work...
		const scrollY = document.body.scrollTop || document.documentElement.scrollTop;
		const scrollX = document.body.scrollLeft || document.documentElement.scrollLeft;

		// Prevent the scrolling we're about to do from triggering this event handler again.
		this.toolbarAnimating = true;

		const $overlaySurface = this.$overlaySurface;
		// Scroll down and translate the surface by the same amount, otherwise the content at new
		// scroll position visibly flashes.
		$overlaySurface.css( 'transform', 'translateY( ' + viewportHeight + 'px )' );
		window.scroll( scrollX, scrollY + viewportHeight );

		// Prepate to animate toolbar sliding into view
		$header.removeClass( 'toolbar-shown toolbar-shown-done' );
		const headerHeight = $header[ 0 ].offsetHeight;
		const headerTranslateY = Math.max( -headerHeight, pos );
		$header.css( 'transform', 'translateY( ' + headerTranslateY + 'px )' );

		// The scroll back up must be after a delay, otherwise no scrolling happens and the
		// viewports are not aligned.
		setTimeout( () => {
			// Scroll back up
			$overlaySurface.css( 'transform', '' );
			window.scroll( scrollX, scrollY );

			// Animate toolbar sliding into view
			$header.addClass( 'toolbar-shown' ).css( 'transform', '' );
			setTimeout( () => {
				$header.addClass( 'toolbar-shown-done' );
				// Wait until the animation is done before allowing this event handler to trigger again
				this.toolbarAnimating = false;
				// Re-check after the animation is done, in case the user scrolls in the meantime.
				animateToolbarIntoView();
				// The animation takes 250ms but we need to wait longer for some reason…
				// 'transitionend' event also doesn't seem to work reliably.
			}, 300 );
			// If the delays below are made any smaller, the weirdest graphical glitches happen,
			// so don't mess with them
		}, 50 );
	}, 250 );
};

/**
 * Handle surface scroll events
 */
ve.init.mw.MobileArticleTarget.prototype.onSurfaceScroll = function () {
	if ( ve.init.platform.constructor.static.isIos() && this.getSurface() ) {
		// iOS has a bug where if you change the scroll offset of a
		// contentEditable or textarea with a cursor visible, it disappears.
		// This function works around it by removing and reapplying the selection.
		const nativeSelection = this.getSurface().getView().nativeSelection;
		if ( nativeSelection.rangeCount && document.activeElement.contentEditable === 'true' ) {
			const range = nativeSelection.getRangeAt( 0 );
			nativeSelection.removeAllRanges();
			nativeSelection.addRange( range );
		}
	}
};

/**
 * @inheritdoc
 */
ve.init.mw.MobileArticleTarget.prototype.createSurface = function ( dmDoc, config ) {
	if ( this.overlay.isNewPage ) {
		config = ve.extendObject( {
			placeholder: this.overlay.options.placeholder
		}, config );
	}

	// Parent method
	const surface = ve.init.mw.MobileArticleTarget
		.super.prototype.createSurface.call( this, dmDoc, config );

	surface.connect( this, { scroll: 'onSurfaceScroll' } );

	return surface;
};

/**
 * @inheritdoc
 */
ve.init.mw.MobileArticleTarget.prototype.getSurfaceClasses = function () {
	const classes = ve.init.mw.MobileArticleTarget.super.prototype.getSurfaceClasses.call( this );
	return [ ...classes, 'content' ];
};

/**
 * @inheritdoc
 */
ve.init.mw.MobileArticleTarget.prototype.setSurface = function ( surface ) {
	const changed = surface !== this.surface;

	// Parent method
	// FIXME This actually skips ve.init.mw.Target.prototype.setSurface. Why?
	ve.init.mw.Target.super.prototype.setSurface.apply( this, arguments );

	if ( changed ) {
		this.$overlaySurface.append( surface.$element );
	}
};

/**
 * @inheritdoc
 */
ve.init.mw.MobileArticleTarget.prototype.surfaceReady = function () {
	if ( this.teardownPromise ) {
		// Loading was cancelled, the overlay is already closed at this point. Do nothing.
		// Otherwise e.g. scrolling from #goToHeading would kick in and mess things up.
		return;
	}

	// Deactivate the surface so any initial selection set in surfaceReady
	// listeners doesn't cause the keyboard to be shown.
	this.getSurface().getView().deactivate( false );

	// Parent method
	ve.init.mw.MobileArticleTarget.super.prototype.surfaceReady.apply( this, arguments );

	// If no selection has been set yet, set it to the start of the document.
	if ( this.getSurface().getModel().getSelection().isNull() ) {
		this.getSurface().getView().selectFirstSelectableContentOffset();
	}

	this.events.trackActivationComplete();

	if ( ve.init.platform.constructor.static.isIos() ) {
		if ( this.viewportZoomHandler ) {
			this.viewportZoomHandler.detach();
		}
		this.viewportZoomHandler = new ve.init.mw.ViewportZoomHandler();
		this.viewportZoomHandler.attach( this.getSurface() );
	}
};

/**
 * @inheritdoc
 */
ve.init.mw.MobileArticleTarget.prototype.afterSurfaceReady = function () {
	this.adjustContentPadding();

	// Parent method
	ve.init.mw.MobileArticleTarget.super.prototype.afterSurfaceReady.apply( this, arguments );
};

/**
 * Match the content padding to the toolbar height
 */
ve.init.mw.MobileArticleTarget.prototype.adjustContentPadding = function () {
	const surface = this.getSurface(),
		surfaceView = surface.getView(),
		toolbarHeight = this.getToolbar().$element[ 0 ].clientHeight;

	surface.setPadding( {
		top: toolbarHeight
	} );
	surfaceView.$attachedRootNode.css( 'padding-top', toolbarHeight );
	surface.$placeholder.css( 'padding-top', toolbarHeight );
	surfaceView.emit( 'position' );
	surface.scrollSelectionIntoView();
};

/**
 * @inheritdoc
 */
ve.init.mw.MobileArticleTarget.prototype.switchToFallbackWikitextEditor = function ( modified ) {
	let dataPromise;
	if ( modified ) {
		dataPromise = this.getWikitextDataPromiseForDoc( modified ).then( ( response ) => {
			const content = ve.getProp( response, 'visualeditoredit', 'content' );
			return { text: content };
		} );
	}
	this.overlay.switchToSourceEditor( dataPromise );
	return dataPromise;
};

/**
 * @inheritdoc
 */
ve.init.mw.MobileArticleTarget.prototype.save = function () {
	// Parent method
	const promise = ve.init.mw.MobileArticleTarget.super.prototype.save.apply( this, arguments );

	this.overlay.log( {
		action: 'saveAttempt'
	} );

	return promise;
};

/**
 * @inheritdoc
 */
ve.init.mw.MobileArticleTarget.prototype.showSaveDialog = function () {
	// Parent method
	ve.init.mw.MobileArticleTarget.super.prototype.showSaveDialog.apply( this, arguments );

	this.overlay.log( {
		action: 'saveIntent'
	} );
};

/**
 * @inheritdoc
 */
ve.init.mw.MobileArticleTarget.prototype.replacePageContent = function (
	html, categoriesHtml, displayTitle, lastModified /* , contentSub, sections */
) {
	// Parent method
	ve.init.mw.MobileArticleTarget.super.prototype.replacePageContent.apply( this, arguments );

	if ( lastModified ) {
		// TODO: Update the last-modified-bar with the correct info
		// eslint-disable-next-line no-jquery/no-global-selector
		$( '.last-modified-bar' ).remove();
	}
};

/**
 * @inheritdoc
 */
ve.init.mw.MobileArticleTarget.prototype.saveComplete = function ( data ) {
	// Set 'saved' flag before teardown (which is called in parent method) to avoid prompts
	// This is set in this.overlay.onSaveComplete, but we can't call that until we have
	// computed the fragment.
	this.overlay.saved = true;

	// Parent method
	ve.init.mw.MobileArticleTarget.super.prototype.saveComplete.apply( this, arguments );

	const fragment = this.getSectionHashFromPage().slice( 1 );

	this.overlay.sectionId = fragment;
	this.overlay.onSaveComplete( data.newrevid, data.tempusercreatedredirect, data.tempusercreated );
};

/**
 * @inheritdoc
 */
ve.init.mw.MobileArticleTarget.prototype.saveFail = function ( doc, saveData, code, data ) {
	// parent method
	ve.init.mw.MobileArticleTarget.super.prototype.saveFail.apply( this, arguments );

	this.overlay.onSaveFailure( data );
};

/**
 * @inheritdoc
 */
ve.init.mw.MobileArticleTarget.prototype.tryTeardown = function () {
	this.overlay.onExitClick( $.Event() );
};

/**
 * @inheritdoc
 */
ve.init.mw.MobileArticleTarget.prototype.setupToolbar = function ( surface ) {
	const originalToolbarGroups = this.toolbarGroups;

	// We don't want any of these tools to show up in subordinate widgets, so we
	// temporarily add them here. We need to do it _here_ rather than in their
	// own static variable to make sure that other tools which meddle with
	// toolbarGroups (Cite, mostly) have a chance to do so.
	this.toolbarGroups = [
		// Back
		{
			name: 'back',
			include: [ 'back' ]
		},
		...this.toolbarGroups,
		{
			name: 'editMode',
			type: 'list',
			icon: 'edit',
			title: ve.msg( 'visualeditor-mweditmode-tooltip' ),
			label: ve.msg( 'visualeditor-mweditmode-tooltip' ),
			invisibleLabel: true,
			include: [ { group: 'editMode' } ]
		},
		{
			name: 'save',
			type: 'bar',
			include: [ 'showSave' ]
		}
	];

	// Parent method
	ve.init.mw.MobileArticleTarget.super.prototype.setupToolbar.call( this, surface );

	this.toolbarGroups = originalToolbarGroups;

	this.toolbar.$element.addClass( 've-init-mw-mobileArticleTarget-toolbar' );
	this.toolbar.$popups.addClass( 've-init-mw-mobileArticleTarget-toolbar-popups' );
};

/**
 * @inheritdoc
 */
ve.init.mw.MobileArticleTarget.prototype.attachToolbar = function () {
	// Move the toolbar to the overlay header
	this.overlay.$el.find( '.overlay-header > .toolbar' ).append( this.toolbar.$element );
	this.toolbar.initialize();
};

/**
 * @inheritdoc
 */
ve.init.mw.MobileArticleTarget.prototype.setupToolbarSaveButton = function () {
	this.toolbarSaveButton = this.toolbar.getToolGroupByName( 'save' ).items[ 0 ];
};

/**
 * @inheritdoc
 */
ve.init.mw.MobileArticleTarget.prototype.goToHeading = function ( headingNode ) {
	this.scrollToHeading( headingNode );
};

/**
 * Done with the editing toolbar
 */
ve.init.mw.MobileArticleTarget.prototype.done = function () {
	this.getSurface().getModel().setNullSelection();
	this.getSurface().getView().blur();
};

/* Registration */

ve.init.mw.targetFactory.register( ve.init.mw.MobileArticleTarget );