From 37627ad9ae4b9d4590abe6ffdcd5301e66e7b602 Mon Sep 17 00:00:00 2001 From: David Lynch Date: Mon, 22 Jul 2024 09:55:55 -0500 Subject: [PATCH] EditCheck: move checks to a sidebar Bug: T341308 Bug: T379443 Change-Id: I66147d95fc23d0f72960ff93a76b3e5ba65ce44e --- editcheck/i18n/en.json | 1 + editcheck/i18n/qqq.json | 1 + editcheck/modules/BaseEditCheck.js | 24 +- editcheck/modules/EditCheck.less | 217 +++++++++++- editcheck/modules/EditCheckAction.js | 147 ++++++++- editcheck/modules/EditCheckDialog.js | 312 ++++++++++++++++-- editcheck/modules/EditCheckFactory.js | 6 +- editcheck/modules/EditCheckInspector.js | 1 - .../editchecks/AddReferenceEditCheck.js | 37 +-- .../modules/editchecks/TextMatchEditCheck.js | 6 + editcheck/modules/init.js | 178 +++------- extension.json | 9 +- includes/Hooks.php | 1 + 13 files changed, 765 insertions(+), 175 deletions(-) diff --git a/editcheck/i18n/en.json b/editcheck/i18n/en.json index d45ae646c4..062b1d10ae 100644 --- a/editcheck/i18n/en.json +++ b/editcheck/i18n/en.json @@ -18,6 +18,7 @@ "editcheck-dialog-addref-success-notify": "Thank you for adding a citation!", "editcheck-dialog-addref-title": "Add a citation", "editcheck-dialog-title": "Before publishing", + "editcheck-review-title": "Review changes", "tag-editcheck-reference-decline-common-knowledge": "Edit Check (references) declined (common knowledge)", "tag-editcheck-reference-decline-common-knowledge-description": "EditCheck reference was declined as common knowledge", "tag-editcheck-reference-decline-irrelevant": "Edit Check (references) declined (irrelevant)", diff --git a/editcheck/i18n/qqq.json b/editcheck/i18n/qqq.json index 036425a2a1..cd28cdbb1f 100644 --- a/editcheck/i18n/qqq.json +++ b/editcheck/i18n/qqq.json @@ -19,6 +19,7 @@ "editcheck-dialog-addref-success-notify": "Notification messages shown after a citation is added successfully.", "editcheck-dialog-addref-title": "Title for the edit check context asking user to add a citation.", "editcheck-dialog-title": "Title shown in the toolbar while the user is in the add a citation workflow.", + "editcheck-review-title": "Title shown in the sidebar / drawer while checks are displayed", "tag-editcheck-reference-decline-common-knowledge": "Short description of the editcheck-reference-decline-common-knowledge tag.\n\nTag added when a user declines to add a suggested reference and selects the \"common knowledge\" reason.\n\nSee also:\n* {{msg-mw|editcheck-dialog-addref-reject-common-knowledge}}", "tag-editcheck-reference-decline-common-knowledge-description": "Long description of the editcheck-reference-decline-common-knowledge tag.\n\nTag added when a user declines to add a suggested reference and selects the \"common knowledge\" reason.\n\nSee also:\n* {{msg-mw|editcheck-dialog-addref-reject-common-knowledge}}", "tag-editcheck-reference-decline-irrelevant": "Short description of the editcheck-reference-decline-irrelevant tag.\n\nTag added when a user declines to add a suggested reference and selects the \"irrelevant\" reason.\n\nSee also:\n* {{msg-mw|editcheck-dialog-addref-reject-irrelevant}}", diff --git a/editcheck/modules/BaseEditCheck.js b/editcheck/modules/BaseEditCheck.js index 6e130e02dd..0eb3246c34 100644 --- a/editcheck/modules/BaseEditCheck.js +++ b/editcheck/modules/BaseEditCheck.js @@ -26,8 +26,19 @@ mw.editcheck.BaseEditCheck.static.defaultConfig = { ignoreLeadSection: false }; +mw.editcheck.BaseEditCheck.static.title = ve.msg( 'editcheck-review-title' ); + mw.editcheck.BaseEditCheck.static.description = ve.msg( 'editcheck-dialog-addref-description' ); +/** + * Get the name of the check type + * + * @return {string} Check type name + */ +mw.editcheck.BaseEditCheck.prototype.getName = function () { + return this.constructor.static.name; +}; + /** * @param {ve.dm.Surface} surfaceModel * @return {mw.editcheck.EditCheckAction[]} @@ -43,7 +54,8 @@ mw.editcheck.BaseEditCheck.prototype.onDocumentChange = null; /** * @param {string} choice `action` key from static.choices * @param {mw.editcheck.EditCheckAction} action - * @param {ve.ui.EditCheckContextItem} contextItem + * @param {ve.ui.Surface} surface + * @return {jQuery.Promise} Promise which resolves when action is complete */ mw.editcheck.BaseEditCheck.prototype.act = null; @@ -55,6 +67,16 @@ mw.editcheck.BaseEditCheck.prototype.getChoices = function () { return this.constructor.static.choices; }; +/** + * Get the title of the check + * + * @param {mw.editcheck.EditCheckAction} action + * @return {jQuery|string|Function|OO.ui.HtmlSnippet} + */ +mw.editcheck.BaseEditCheck.prototype.getTitle = function () { + return this.constructor.static.title; +}; + /** * @param {mw.editcheck.EditCheckAction} action * @return {string} diff --git a/editcheck/modules/EditCheck.less b/editcheck/modules/EditCheck.less index ad1b7538b0..4d062af284 100644 --- a/editcheck/modules/EditCheck.less +++ b/editcheck/modules/EditCheck.less @@ -1,8 +1,29 @@ +@import '../../lib/codex-design-tokens/theme-wikimedia-ui.less'; + +@media ( max-width: 1492px ) { + /* Hides the Vector sidebar while an editcheck-enabled editing session is occurring. See T379443. */ + /* stylelint-disable selector-class-pattern */ + .ve-editcheck-available { + .mw-body .vector-column-end, + .vector-pinnable-header-pin-button { + display: none !important; /* stylelint-disable-line declaration-no-important */ + } + } + /* stylelint-enable selector-class-pattern */ +} + /* Toolbar */ .ve-ui-editCheck-toolbar { + font-size: 0.875rem; // ignore content scaling + + .mw-mf & { + font-size: inherit; + } + .oo-ui-toolbar-bar { display: flex; + flex-wrap: wrap; } .oo-ui-toolbar-tools { @@ -14,6 +35,12 @@ flex: 0; } + .oo-ui-toolbar-actions + div { + // There's a clear div that we need to enhance slightly + flex-basis: 100%; + height: 0; + } + &.ve-init-mw-mobileArticleTarget-toolbar .oo-ui-toolbar-tools.oo-ui-toolbar-after { display: none; } @@ -44,6 +71,162 @@ border-bottom-color: #fce7fe; } +/* Actions */ + +.ve-ui-editCheckDialog { + font-size: 0.875rem; // ignore content scaling + + .oo-ui-window-body { + padding-left: 12px; + } + + &-title { + display: block; + font-weight: bold; + padding: @spacing-50 0; + margin-bottom: @spacing-50; + border-bottom: 1px solid @border-color-base; + } + + &-footer { + display: flex; + justify-content: flex-end; + + > .oo-ui-labelWidget { + align-content: center; + } + } + + .ve-ui-editCheckActionWidget { + box-sizing: border-box; + border: @border-base; + margin: @spacing-100 0; + white-space: normal; // Minerva needs this + + &-head { + position: relative; + padding: @spacing-50 @spacing-75; + + > .oo-ui-labelElement-label { + display: block; + margin-left: 2em; + font-weight: @font-weight-semi-bold; + } + } + + &-body { + margin: @spacing-50 @spacing-75 @spacing-100; + } + + &-actions { + margin-top: @spacing-100; + } + + &.ve-ui-editCheckActionWidget-collapsed { + filter: grayscale( 1 ); + + > .ve-ui-editCheckActionWidget-body { + display: none; + } + } + + &.oo-ui-flaggedElement-warning { + border-color: @border-color-warning; + + > .ve-ui-editCheckActionWidget-head { + background-color: @background-color-warning-subtle; + } + } + + &.oo-ui-flaggedElement-error { + border-color: @border-color-error; + + > .ve-ui-editCheckActionWidget-head { + background-color: @background-color-error-subtle; + } + } + + &.oo-ui-flaggedElement-success { + border-color: @border-color-success; + + > .ve-ui-editCheckActionWidget-head { + background-color: @background-color-success-subtle; + } + } + + &.oo-ui-flaggedElement-notice { + border-color: @border-color-notice; + + > .ve-ui-editCheckActionWidget-head { + background-color: @background-color-notice-subtle; + } + } + } + + &.ve-ui-editCheckDialog-singleAction .ve-ui-editCheckActionWidget-collapsed { + display: none; + } + + .mw-mf & { + top: auto; + // See: .ve-ui-mobileContext, which this is closely mimicking + background-color: @background-color-interactive-subtle; + /* Match toolbar border & shadow */ + border-top: @border-subtle; + box-shadow: 0 -1px 1px 0 rgba( 0, 0, 0, 0.1 ); + /* Transition out faster, as keyboard may be coming up */ + transition: transform 100ms; + transform: translateY( 0% ); + max-width: 995px; + margin: 0 auto; + + &-title { + padding: @spacing-75 @spacing-100; + margin-bottom: 0; + } + + &-close { + position: absolute; + right: 0; + top: 2px; + } + + &-footer { + margin: 0 @spacing-100 @spacing-50; + } + + &.ve-ui-editCheckDialog-collapsed { + display: block; + transition: transform 250ms; + transform: translateY( calc( 100% - 2.5em ) ); + } + + .oo-ui-window-body { + padding: 0; + } + + .ve-ui-editCheckActionWidget { + margin: 0; + border-width: 0; + + &-head { + background-color: transparent !important; /* stylelint-disable-line declaration-no-important */ + } + + &-body { + padding-left: 2em; + margin-bottom: @spacing-75; + } + } + } +} + +.mw-mf .ve-ce-surface-reviewMode.ve-ce-surface-deactivated { + // Otherwise the content will be covered by the mobile context at the end + // of the document. (Upstream this?) + margin-bottom: 100%; +} + /* Selections */ .ve-ce-surface-reviewMode + .ve-ui-overlay .ve-ce-surface-selections-editCheck .ve-ce-surface-selection { @@ -68,9 +251,41 @@ mix-blend-mode: darken; // Adjust target colours to account for 50% opacity background: ( #fef6e7 - 0.8 * ( #fff ) ) / 0.2; - border: 1px solid ( ( #a66200 - 0.8 * ( #fff ) ) / 0.2 ); + // border: 1px solid ( ( #a66200 - 0.8 * ( #fff ) ) / 0.2 ); border-radius: 2px; padding: 2px; margin: -2px 0 0 -2px; } } + +.ve-ui-editCheck-gutter-highlight { + position: absolute; + left: 0; + width: 2px; + overflow: hidden; + background-color: @color-base; + + &-error { + background-color: @color-error; + } + + &-warning { + background-color: @color-warning; + } + + &-notice { + background-color: @color-notice; + } + + &-success { + background-color: @color-success; + } + + &-inactive { + background-color: @border-color-base; + } + + .mw-mf & { + left: -10px; + } +} diff --git a/editcheck/modules/EditCheckAction.js b/editcheck/modules/EditCheckAction.js index 5cfd6aaa12..7cf980f97e 100644 --- a/editcheck/modules/EditCheckAction.js +++ b/editcheck/modules/EditCheckAction.js @@ -5,15 +5,28 @@ * @param {mw.editcheck.BaseEditCheck} check * @param {ve.dm.SurfaceFragment[]} fragments Affected fragments * @param {jQuery|string|Function|OO.ui.HtmlSnippet} message Check message body + * @param {jQuery|string|Function|OO.ui.HtmlSnippet} title Check title */ mw.editcheck.EditCheckAction = function MWEditCheckAction( config ) { this.check = config.check; this.fragments = config.fragments; this.message = config.message; + this.title = config.title; + this.icon = config.icon; + this.type = config.type || 'warning'; }; OO.initClass( mw.editcheck.EditCheckAction ); +/** + * Get the action's title + * + * @return {jQuery|string|Function|OO.ui.HtmlSnippet} + */ +mw.editcheck.EditCheckAction.prototype.getTitle = function () { + return this.title || this.check.getTitle( this ); +}; + /** * Get the available choices * @@ -38,5 +51,137 @@ mw.editcheck.EditCheckAction.prototype.getHighlightSelections = function () { * @return {string} */ mw.editcheck.EditCheckAction.prototype.getDescription = function () { - return this.check.getDescription( this ); + return this.message || this.check.getDescription( this ); +}; + +mw.editcheck.EditCheckAction.prototype.getType = function () { + return this.type; +}; + +/** + * Get the name of the check type + * + * @return {string} Check type name + */ +mw.editcheck.EditCheckAction.prototype.getName = function () { + return this.check.getName(); +}; + +mw.editcheck.EditCheckAction.prototype.render = function ( collapsed, singleAction, surface ) { + const widget = new mw.editcheck.EditCheckActionWidget( { + type: this.type, + icon: this.icon, + label: this.getTitle(), + message: this.getDescription(), + classes: collapsed ? [ 've-ui-editCheckActionWidget-collapsed' ] : '', + singleAction: singleAction + } ); + this.getChoices().forEach( ( choice ) => { + const button = new OO.ui.ButtonWidget( choice ); + button.connect( this, { + click: () => { + const promise = this.check.act( choice.action, this, surface ) || ve.createDeferred().resolve().promise(); + widget.emit( 'act', choice, choice.action, promise ); + } + } ); + widget.addAction( button ); + } ); + + return widget; +}; + +mw.editcheck.EditCheckActionWidget = function MWEditCheckActionWidget( config ) { + // Configuration initialization + config = config || {}; + + this.singleAction = config.singleAction; + + this.actions = []; + + // Parent constructor + mw.editcheck.EditCheckActionWidget.super.call( this, config ); + + // Mixin constructors + OO.ui.mixin.IconElement.call( this, config ); + OO.ui.mixin.LabelElement.call( this, config ); + OO.ui.mixin.TitledElement.call( this, config ); + OO.ui.mixin.FlaggedElement.call( this, config ); + + this.setType( config.type ); + + if ( config.icon ) { + this.setIcon( config.icon ); + } + + this.message = new OO.ui.LabelWidget( { label: config.message } ); + this.$actions = $( '
' ).addClass( 've-ui-editCheckActionWidget-actions oo-ui-element-hidden' ); + + this.$head = $( '
' ) + .append( this.$icon, this.$label ) + .addClass( 've-ui-editCheckActionWidget-head' ) + .on( 'click', this.onHeadClick.bind( this ) ); + this.$body = $( '
' ) + .append( this.message.$element, this.$actions ) + .addClass( 've-ui-editCheckActionWidget-body' ); + + this.$element + .append( this.$head, this.$body ) + // .append( this.$icon, this.$label, this.closeButton && this.closeButton.$element ) + .addClass( 've-ui-editCheckActionWidget' ); +}; + +OO.inheritClass( mw.editcheck.EditCheckActionWidget, OO.ui.Widget ); +OO.mixinClass( mw.editcheck.EditCheckActionWidget, OO.ui.mixin.IconElement ); +OO.mixinClass( mw.editcheck.EditCheckActionWidget, OO.ui.mixin.LabelElement ); +OO.mixinClass( mw.editcheck.EditCheckActionWidget, OO.ui.mixin.TitledElement ); +OO.mixinClass( mw.editcheck.EditCheckActionWidget, OO.ui.mixin.FlaggedElement ); + +/** + * @inheritdoc + */ +mw.editcheck.EditCheckActionWidget.prototype.setDisabled = function ( disabled ) { + OO.ui.Widget.prototype.setDisabled.call( this, disabled ); + this.actions.forEach( ( action ) => { + action.setDisabled( disabled ); + } ); +}; + +mw.editcheck.EditCheckActionWidget.static.iconMap = { + notice: 'infoFilled', + error: 'error', + warning: 'alert' +}; + +mw.editcheck.EditCheckActionWidget.prototype.setType = function ( type ) { + if ( !this.constructor.static.iconMap[ type ] ) { + type = 'notice'; + } + if ( type !== this.type ) { + this.clearFlags(); + this.setFlags( type ); + + this.setIcon( this.constructor.static.iconMap[ type ] ); + } + this.type = type; +}; + +mw.editcheck.EditCheckActionWidget.prototype.getType = function () { + return this.type; +}; + +mw.editcheck.EditCheckActionWidget.prototype.addAction = function ( action ) { + this.actions.push( action ); + this.$actions.append( action.$element ).removeClass( 'oo-ui-element-hidden' ); +}; + +mw.editcheck.EditCheckActionWidget.prototype.onHeadClick = function ( e ) { + if ( this.singleAction ) { + return; + } + + e.preventDefault(); + // eslint-disable-next-line no-jquery/no-class-state + this.$element.toggleClass( 've-ui-editCheckActionWidget-collapsed' ); + // eslint-disable-next-line no-jquery/no-class-state + this.emit( 'togglecollapse', this.$element.hasClass( 've-ui-editCheckActionWidget-collapsed' ) ); }; diff --git a/editcheck/modules/EditCheckDialog.js b/editcheck/modules/EditCheckDialog.js index 8af0141e63..f3c5f0ac95 100644 --- a/editcheck/modules/EditCheckDialog.js +++ b/editcheck/modules/EditCheckDialog.js @@ -27,15 +27,14 @@ OO.inheritClass( ve.ui.EditCheckDialog, ve.ui.ToolbarDialog ); ve.ui.EditCheckDialog.static.name = 'editCheckDialog'; -ve.ui.EditCheckDialog.static.position = 'side'; +ve.ui.EditCheckDialog.static.position = OO.ui.isMobile() ? 'below' : 'side'; -ve.ui.EditCheckDialog.static.size = 'medium'; +ve.ui.EditCheckDialog.static.size = OO.ui.isMobile() ? 'full' : 'medium'; ve.ui.EditCheckDialog.static.framed = false; -// // Invisible title for accessibility -// ve.ui.EditCheckDialog.static.title = -// OO.ui.deferMsg( 'visualeditor-find-and-replace-title' ); +// Invisible title for accessibility +ve.ui.EditCheckDialog.static.title = OO.ui.deferMsg( 'editcheck-review-title' ); /* Methods */ @@ -46,26 +45,190 @@ ve.ui.EditCheckDialog.prototype.initialize = function () { // Parent method ve.ui.EditCheckDialog.super.prototype.initialize.call( this ); + this.title = new OO.ui.LabelWidget( { + label: this.constructor.static.title, + classes: [ 've-ui-editCheckDialog-title' ] + } ); + + // FIXME: click handlers are getting unbound when the window is closed + + this.closeButton = new OO.ui.ButtonWidget( { + classes: [ 've-ui-editCheckDialog-close' ], + framed: false, + label: ve.msg( 'visualeditor-contextitemwidget-label-close' ), + invisibleLabel: true, + icon: 'expand' + } ).connect( this, { + click: 'onCloseButtonClick' + } ); + + this.currentOffset = 0; + + this.footerLabel = new OO.ui.LabelWidget(); + this.previousButton = new OO.ui.ButtonWidget( { + icon: 'previous', + title: ve.msg( 'last' ), + invisibleLabel: true, + framed: false + } ).connect( this, { + click: 'onPreviousButtonClick' + } ); + this.nextButton = new OO.ui.ButtonWidget( { + icon: 'next', + title: ve.msg( 'next' ), + invisibleLabel: true, + framed: false + } ).connect( this, { + click: 'onNextButtonClick' + } ); + this.footer = new OO.ui.HorizontalLayout( { + classes: [ 've-ui-editCheckDialog-footer' ], + items: [ + this.footerLabel, + this.previousButton, + this.nextButton + ] + } ); + + this.$checks = $( '
' ); + this.$body.append( this.title.$element, this.closeButton.$element, this.$checks, this.footer.$element ); + + this.$highlights = $( '
' ); + this.updateDebounced = ve.debounce( this.update.bind( this ), 100 ); + this.positionDebounced = ve.debounce( this.position.bind( this ), 100 ); }; ve.ui.EditCheckDialog.prototype.update = function () { const surfaceView = this.surface.getView(); - const checks = mw.editcheck.editCheckFactory.createAllByListener( 'onDocumentChange', this.surface.getModel() ); - const $checks = $( '
' ); - const selections = []; - checks.forEach( ( check ) => { - $checks.append( new OO.ui.MessageWidget( { - type: 'warning', - label: check.message, - framed: false - } ).$element ); + // We only regenerate the checks on-change during the edit. If we're in + // the proofreading step, no new checks should appear based on changes: + if ( this.listener === 'onDocumentChange' || !this.currentChecks ) { + this.currentChecks = mw.editcheck.editCheckFactory.createAllByListener( this.listener, this.surface.getModel() ); + } + if ( this.listener === 'onBeforeSave' && this.currentChecks.length === 0 ) { + return this.close( 'complete' ); + } + const checks = this.currentChecks; + const newOffset = Math.min( this.currentOffset, checks.length - 1 ); + this.$checks.empty(); + this.$highlights.empty(); + + checks.forEach( ( check, index ) => { + const widget = check.render( index !== newOffset, this.listener === 'onBeforeSave', this.surface ); + widget.on( 'togglecollapse', this.onToggleCollapse, [ check, index ], this ); + widget.on( 'act', this.onAct, [ widget ], this ); + this.$checks.append( widget.$element ); + check.widget = widget; + } ); + + if ( this.reviewMode ) { + // Review mode grays out everything that's not highlighted: + const highlightNodes = []; + checks.forEach( ( check ) => { + check.getHighlightSelections().forEach( ( selection ) => { + highlightNodes.push.apply( highlightNodes, surfaceView.getDocument().selectNodes( selection.getCoveringRange(), 'branches' ).map( ( spec ) => spec.node ) ); + } ); + } ); + surfaceView.setReviewMode( true, highlightNodes ); + } + + this.setCurrentOffset( newOffset ); +}; + +ve.ui.EditCheckDialog.prototype.position = function () { + this.drawHighlights(); + this.scrollCurrentCheckIntoView(); +}; + +ve.ui.EditCheckDialog.prototype.drawHighlights = function () { + const surfaceView = this.surface.getView(); + this.$highlights.empty(); + + this.currentChecks.forEach( ( check, index ) => { check.getHighlightSelections().forEach( ( selection ) => { - selections.push( ve.ce.Selection.static.newFromModel( selection, surfaceView ) ); + const selectionView = ve.ce.Selection.static.newFromModel( selection, surfaceView ); + const rect = selectionView.getSelectionBoundingRect(); + // The following classes are used here: + // * ve-ui-editCheck-gutter-highlight-error + // * ve-ui-editCheck-gutter-highlight-warning + // * ve-ui-editCheck-gutter-highlight-notice + // * ve-ui-editCheck-gutter-highlight-success + // * ve-ui-editCheck-gutter-highlight-active + // * ve-ui-editCheck-gutter-highlight-inactive + this.$highlights.append( $( '
' ) + .addClass( 've-ui-editCheck-gutter-highlight' ) + .addClass( 've-ui-editCheck-gutter-highlight-' + check.getType() ) + .addClass( 've-ui-editCheck-gutter-highlight-' + ( index === this.currentOffset ? 'active' : 'inactive' ) ) + .css( { + top: rect.top - 2, + height: rect.height + 4 + } ) + ); } ); } ); - surfaceView.getSelectionManager().drawSelections( 'editCheckWarning', selections ); - this.$body.empty().append( $checks ); + + surfaceView.appendHighlights( this.$highlights, false ); +}; + +/** + * Set the offset of the current check, within the list of all checks + * + * @param {number} offset + */ +ve.ui.EditCheckDialog.prototype.setCurrentOffset = function ( offset ) { + // TODO: work out how to tell the window to recalculate height here + this.currentOffset = Math.max( 0, offset ); + + this.$body.find( '.ve-ui-editCheckActionWidget' ).each( ( i, el ) => { + $( el ).toggleClass( 've-ui-editCheckActionWidget-collapsed', i !== this.currentOffset ); + } ); + + this.footerLabel.setLabel( + ve.msg( 'visualeditor-find-and-replace-results', + ve.init.platform.formatNumber( this.currentOffset + 1 ), + ve.init.platform.formatNumber( this.currentChecks.length ) + ) + ); + this.nextButton.setDisabled( this.currentOffset >= this.currentChecks.length - 1 ); + this.previousButton.setDisabled( this.currentOffset <= 0 ); + + this.updateSize(); + + const surfaceView = this.surface.getView(); + if ( this.currentChecks.length > 0 ) { + // The currently-focused check gets a selection: + // TODO: clicking the selection should activate the sidebar-action + surfaceView.getSelectionManager().drawSelections( + 'editCheckWarning', + this.currentChecks[ this.currentOffset ].getHighlightSelections().map( + ( selection ) => ve.ce.Selection.static.newFromModel( selection, surfaceView ) + ) + ); + + this.scrollCurrentCheckIntoView(); + } else { + surfaceView.getSelectionManager().drawSelections( 'editCheckWarning', [] ); + } + + this.drawHighlights(); +}; + +ve.ui.EditCheckDialog.prototype.scrollCurrentCheckIntoView = function () { + const currentCheck = this.currentChecks[ this.currentOffset ]; + if ( currentCheck ) { + // scrollSelectionIntoView scrolls to the focus of a selection, but we + // want the very beginning to be in view, so collapse it: + const selection = currentCheck.getHighlightSelections()[ 0 ].collapseToStart(); + this.surface.scrollSelectionIntoView( selection, { + animate: true, + padding: { + top: ( OO.ui.isMobile() ? 80 : currentCheck.widget.$element[ 0 ].getBoundingClientRect().top ), + bottom: ( OO.ui.isMobile() ? this.getContentHeight() : 0 ) + 20 + }, + alignToTop: true + } ); + } }; /** @@ -74,8 +237,24 @@ ve.ui.EditCheckDialog.prototype.update = function () { ve.ui.EditCheckDialog.prototype.getSetupProcess = function ( data ) { return ve.ui.EditCheckDialog.super.prototype.getSetupProcess.call( this, data ) .first( () => { + this.currentOffset = 0; + this.listener = data.listener || 'onDocumentChange'; + this.reviewMode = data.reviewMode; this.surface = data.surface; + this.surface.getModel().on( 'undoStackChange', this.updateDebounced ); + this.surface.getView().on( 'position', this.positionDebounced ); + + this.closeButton.toggle( OO.ui.isMobile() ); + this.footer.toggle( + this.listener === 'onBeforeSave' && + !mw.config.get( 'wgVisualEditorConfig' ).editCheckSingle + ); + + this.$element.toggleClass( 've-ui-editCheckDialog-singleAction', this.listener === 'onBeforeSave' ); + + this.surface.context.hide(); + this.update(); }, this ); }; @@ -86,6 +265,12 @@ ve.ui.EditCheckDialog.prototype.getSetupProcess = function ( data ) { ve.ui.EditCheckDialog.prototype.getReadyProcess = function ( data ) { return ve.ui.EditCheckDialog.super.prototype.getReadyProcess.call( this, data ) .next( () => { + // The end of the ready process triggers a reflow after an + // animation, so we need to get past that to avoid the content + // being immediately scrolled away + setTimeout( () => { + this.scrollCurrentCheckIntoView(); + }, 500 ); }, this ); }; @@ -95,17 +280,105 @@ ve.ui.EditCheckDialog.prototype.getReadyProcess = function ( data ) { ve.ui.EditCheckDialog.prototype.getTeardownProcess = function ( data ) { return ve.ui.EditCheckDialog.super.prototype.getTeardownProcess.call( this, data ) .next( () => { + this.surface.getView().setReviewMode( false ); + this.surface.getView().getSelectionManager().drawSelections( 'editCheckWarning', [] ); + this.surface.getView().off( 'position', this.positionDebounced ); this.surface.getModel().off( 'undoStackChange', this.updateDebounced ); + this.$highlights.remove().empty(); + this.$checks.empty(); }, this ); }; +/** + * Handle 'act' events from the mw.widget.EditCheckActionWidget + * + * @param {mw.editcheck.EditCheckActionWidget} widget + * @param {Object} choice Choice object (with 'reason', 'object', 'label') + * @param {string} actionChosen Choice action + * @param {jQuery.Promise} promise Promise which resolves when the action is complete + */ +ve.ui.EditCheckDialog.prototype.onAct = function ( widget, choice, actionChosen, promise ) { + widget.setDisabled( true ); + this.nextButton.setDisabled( true ); + this.previousButton.setDisabled( true ); + promise.then( ( data ) => { + widget.setDisabled( false ); + this.nextButton.setDisabled( false ); + this.previousButton.setDisabled( false ); + this.surface.getModel().setNullSelection(); + if ( OO.ui.isMobile() ) { + // Delay on mobile means we need to rehide this + setTimeout( () => this.surface.getModel().setNullSelection(), 300 ); + } + + if ( !data ) { + // Nothing happened, just fall back and leave the check + return; + } + + if ( this.listener === 'onBeforeSave' ) { + // We must have been acting on the currentOffset + setTimeout( () => { + // We want to linger for a brief moment before moving away + this.currentChecks.splice( this.currentOffset, 1 ); + this.currentOffset = Math.max( 0, this.currentOffset - 1 ); + this.update(); + }, 500 ); + } + } ); +}; + +/** + * Handle 'togglecollapse' events from the mw.widget.EditCheckActionWidget + * + * @param {mw.editcheck.EditCheckAction} check + * @param {number} index + * @param {boolean} collapsed + */ +ve.ui.EditCheckDialog.prototype.onToggleCollapse = function ( check, index, collapsed ) { + if ( !collapsed ) { + // expanded one + this.setCurrentOffset( this.currentChecks.indexOf( check ) ); + } +}; + +/** + * Handle click events from the close button + */ +ve.ui.EditCheckDialog.prototype.onCloseButtonClick = function () { + // eslint-disable-next-line no-jquery/no-class-state + const collapse = !this.$element.hasClass( 've-ui-editCheckDialog-collapsed' ); + this.$element.toggleClass( 've-ui-editCheckDialog-collapsed', collapse ); + this.closeButton.setIcon( collapse ? 'collapse' : 'expand' ); +}; + +/** + * Handle click events from the next button + */ +ve.ui.EditCheckDialog.prototype.onNextButtonClick = function () { + this.setCurrentOffset( this.currentOffset + 1 ); +}; + +/** + * Handle click events from the previous button + */ +ve.ui.EditCheckDialog.prototype.onPreviousButtonClick = function () { + this.setCurrentOffset( this.currentOffset - 1 ); +}; + /* Registration */ ve.ui.windowFactory.register( ve.ui.EditCheckDialog ); ve.ui.commandRegistry.register( new ve.ui.Command( - 'editCheckDialog', 'window', 'toggle', { args: [ 'editCheckDialog' ] } + 'editCheckDialogInProcess', 'window', 'toggle', { args: [ 'editCheckDialog', { listener: 'onDocumentChange' } ] } + ) +); + +ve.ui.commandRegistry.register( + new ve.ui.Command( + 'editCheckDialogBeforeSave', 'window', 'toggle', { args: [ 'editCheckDialog', { listener: 'onBeforeSave', reviewMode: true } ] } ) ); @@ -125,7 +398,8 @@ ve.ui.EditCheckDialogTool.static.group = 'notices'; ve.ui.EditCheckDialogTool.static.icon = 'robot'; ve.ui.EditCheckDialogTool.static.title = 'Edit check'; // OO.ui.deferMsg( 'visualeditor-dialog-command-help-title' ); ve.ui.EditCheckDialogTool.static.autoAddToCatchall = false; -ve.ui.EditCheckDialogTool.static.commandName = 'editCheckDialog'; +ve.ui.EditCheckDialogTool.static.commandName = 'editCheckDialogInProcess'; +// ve.ui.EditCheckDialogTool.static.commandName = 'editCheckDialogBeforeSave'; // Demo button for opening edit check sidebar // ve.ui.toolFactory.register( ve.ui.EditCheckDialogTool ); diff --git a/editcheck/modules/EditCheckFactory.js b/editcheck/modules/EditCheckFactory.js index 0487515075..b514d937a6 100644 --- a/editcheck/modules/EditCheckFactory.js +++ b/editcheck/modules/EditCheckFactory.js @@ -66,7 +66,7 @@ mw.editcheck.EditCheckFactory.prototype.getNamesByListener = function ( listener * @return {mw.editcheck.EditCheckActions[]} Actions, sorted by range */ mw.editcheck.EditCheckFactory.prototype.createAllByListener = function ( listener, surfaceModel ) { - const newChecks = []; + let newChecks = []; this.getNamesByListener( listener ).forEach( ( checkName ) => { const check = this.create( checkName, mw.editcheck.config[ checkName ] ); if ( !check.canBeShown() ) { @@ -80,6 +80,10 @@ mw.editcheck.EditCheckFactory.prototype.createAllByListener = function ( listene newChecks.sort( ( a, b ) => a.getHighlightSelections()[ 0 ].getCoveringRange().start - b.getHighlightSelections()[ 0 ].getCoveringRange().start ); + if ( mw.config.get( 'wgVisualEditorConfig' ).editCheckSingle && listener === 'onBeforeSave' ) { + newChecks = newChecks.filter( ( action ) => action.getName() === 'addReference' ); + newChecks.splice( 1 ); + } return newChecks; }; diff --git a/editcheck/modules/EditCheckInspector.js b/editcheck/modules/EditCheckInspector.js index 8cb23dc3d7..0021dc1fd6 100644 --- a/editcheck/modules/EditCheckInspector.js +++ b/editcheck/modules/EditCheckInspector.js @@ -119,7 +119,6 @@ ve.ui.EditCheckInspector.prototype.getSetupProcess = function ( data ) { return ve.ui.EditCheckInspector.super.prototype.getSetupProcess.call( this, data ) .first( function () { this.surface = data.surface; - this.saveProcessDeferred = data.saveProcessDeferred; this.answerRadioSelect.selectItem( null ); }, this ); }; diff --git a/editcheck/modules/editchecks/AddReferenceEditCheck.js b/editcheck/modules/editchecks/AddReferenceEditCheck.js index 4f2d40470a..c66f7da5e2 100644 --- a/editcheck/modules/editchecks/AddReferenceEditCheck.js +++ b/editcheck/modules/editchecks/AddReferenceEditCheck.js @@ -7,6 +7,8 @@ OO.inheritClass( mw.editcheck.AddReferenceEditCheck, mw.editcheck.BaseEditCheck mw.editcheck.AddReferenceEditCheck.static.name = 'addReference'; +mw.editcheck.AddReferenceEditCheck.static.title = ve.msg( 'editcheck-dialog-addref-title' ); + mw.editcheck.AddReferenceEditCheck.static.description = ve.msg( 'editcheck-dialog-addref-description' ); mw.editcheck.AddReferenceEditCheck.static.defaultConfig = ve.extendObject( {}, mw.editcheck.BaseEditCheck.static.defaultConfig, { @@ -20,9 +22,11 @@ mw.editcheck.AddReferenceEditCheck.prototype.onBeforeSave = function ( surfaceMo return new mw.editcheck.EditCheckAction( { fragments: [ fragment ], check: this + // icon: 'quotes', } ); } ); }; +mw.editcheck.AddReferenceEditCheck.prototype.onDocumentChange = mw.editcheck.AddReferenceEditCheck.prototype.onBeforeSave; /** * Find content ranges which have been inserted @@ -53,9 +57,9 @@ mw.editcheck.AddReferenceEditCheck.prototype.findAddedContent = function ( docum return ranges; }; -mw.editcheck.AddReferenceEditCheck.prototype.act = function ( choice, action, contextItem ) { +mw.editcheck.AddReferenceEditCheck.prototype.act = function ( choice, action, surface ) { // The complex citoid workflow means that we can't just count on a single "windowAction" here... - const windowAction = ve.ui.actionFactory.create( 'window', contextItem.context.getSurface(), 'check' ); + const windowAction = ve.ui.actionFactory.create( 'window', surface, 'check' ); switch ( choice ) { case 'accept': ve.track( 'activity.editCheckReferences', { action: 'edit-check-confirm' } ); @@ -67,7 +71,7 @@ mw.editcheck.AddReferenceEditCheck.prototype.act = function ( choice, action, co if ( citoidData && citoidData.action === 'manual-choose' ) { // The plain reference dialog has been launched. Wait for the data from // the basic Cite closing promise instead. - contextItem.context.getSurface().getDialogs().once( 'closing', ( win, closed, citeData ) => { + surface.getDialogs().once( 'closing', ( win, closed, citeData ) => { citoidOrCiteDataDeferred.resolve( citeData ); } ); } else { @@ -75,18 +79,11 @@ mw.editcheck.AddReferenceEditCheck.prototype.act = function ( choice, action, co // use the data form the Citoid closing promise. citoidOrCiteDataDeferred.resolve( citoidData ); } - citoidOrCiteDataDeferred.promise().then( ( data ) => { - if ( !data ) { - // Reference was not inserted - re-open this context - setTimeout( () => { - // Deactivate again for mobile after teardown has modified selections - contextItem.context.getSurface().getView().deactivate(); - contextItem.context.afterContextChange(); - }, 500 ); - } else { + return citoidOrCiteDataDeferred.promise().done( ( data ) => { + if ( data ) { // Edit check inspector is already closed by this point, but // we need to end the workflow. - contextItem.close( citoidData ); + mw.notify( ve.msg( 'editcheck-dialog-addref-success-notify' ), { type: 'success' } ); } } ); } ); @@ -95,20 +92,14 @@ mw.editcheck.AddReferenceEditCheck.prototype.act = function ( choice, action, co return windowAction.open( 'editCheckReferencesInspector', { - fragment: action.fragments[ 0 ], - callback: contextItem.data.callback, - saveProcessDeferred: contextItem.data.saveProcessDeferred + fragment: action.fragments[ 0 ] } // eslint-disable-next-line arrow-body-style ).then( ( instance ) => { - // contextItem.openingCitoid = false; return instance.closing; - } ).then( ( data ) => { - if ( !data ) { - // Form was closed, re-open this context - contextItem.context.afterContextChange(); - } else { - contextItem.close( data ); + } ).done( ( data ) => { + if ( data && data.action === 'reject' && data.reason ) { + mw.editcheck.rejections.push( data.reason ); } } ); } diff --git a/editcheck/modules/editchecks/TextMatchEditCheck.js b/editcheck/modules/editchecks/TextMatchEditCheck.js index 913eb34ba0..4da3fd3bae 100644 --- a/editcheck/modules/editchecks/TextMatchEditCheck.js +++ b/editcheck/modules/editchecks/TextMatchEditCheck.js @@ -7,10 +7,13 @@ OO.inheritClass( mw.editcheck.TextMatchEditCheck, mw.editcheck.BaseEditCheck ); mw.editcheck.TextMatchEditCheck.static.name = 'textMatch'; +mw.editcheck.TextMatchEditCheck.static.choices = []; + mw.editcheck.TextMatchEditCheck.static.replacers = [ // TODO: Load text replacement rules from community config { query: 'unfortunately', + title: 'Adverb usage', message: new OO.ui.HtmlSnippet( 'Use of adverbs such as "unfortunately" should usually be avoided so as to maintain an impartial tone. Read more.' ) } ]; @@ -23,6 +26,7 @@ mw.editcheck.TextMatchEditCheck.prototype.onDocumentChange = function ( surfaceM actions.push( new mw.editcheck.EditCheckAction( { fragments: [ fragment ], + title: replacer.title, message: replacer.message, check: this } ) @@ -32,4 +36,6 @@ mw.editcheck.TextMatchEditCheck.prototype.onDocumentChange = function ( surfaceM return actions; }; +// mw.editcheck.TextMatchEditCheck.prototype.onBeforeSave = mw.editcheck.TextMatchEditCheck.prototype.onDocumentChange; + mw.editcheck.editCheckFactory.register( mw.editcheck.TextMatchEditCheck ); diff --git a/editcheck/modules/init.js b/editcheck/modules/init.js index df26a8847d..e21e7cc605 100644 --- a/editcheck/modules/init.js +++ b/editcheck/modules/init.js @@ -83,8 +83,12 @@ if ( mw.config.get( 'wgVisualEditorConfig' ).editCheckTagging ) { } if ( mw.config.get( 'wgVisualEditorConfig' ).editCheck || mw.editcheck.ecenable ) { - let saveProcessDeferred; - + mw.hook( 've.activationStart' ).add( () => { + document.documentElement.classList.add( 've-editcheck-available' ); + } ); + mw.hook( 've.deactivationComplete' ).add( () => { + document.documentElement.classList.remove( 've-editcheck-available' ); + } ); mw.hook( 've.preSaveProcess' ).add( ( saveProcess, target ) => { const surface = target.getSurface(); @@ -101,12 +105,11 @@ if ( mw.config.get( 'wgVisualEditorConfig' ).editCheck || mw.editcheck.ecenable // clear rejection-reasons between runs of the save process, so only the last one counts mw.editcheck.rejections.length = 0; - let checks = mw.editcheck.editCheckFactory.createAllByListener( 'onBeforeSave', surface.getModel() ); + const checks = mw.editcheck.editCheckFactory.createAllByListener( 'onBeforeSave', surface.getModel() ); if ( checks.length ) { ve.track( 'counter.editcheck.preSaveChecksShown' ); mw.editcheck.refCheckShown = true; - const surfaceView = surface.getView(); const toolbar = target.getToolbar(); const reviewToolbar = new ve.ui.PositionedTargetToolbar( target, target.toolbarConfig ); reviewToolbar.setup( [ @@ -142,122 +145,51 @@ if ( mw.config.get( 'wgVisualEditorConfig' ).editCheck || mw.editcheck.ecenable target.toolbar.$element.before( reviewToolbar.$element ); target.toolbar = reviewToolbar; - saveProcessDeferred = ve.createDeferred(); - const context = surface.getContext(); - - // TODO: Allow multiple checks to be shown when multicheck is enabled - checks = checks.slice( 0, 1 ); - - // eslint-disable-next-line no-shadow - const drawSelections = ( checks ) => { - const highlightNodes = []; - const selections = []; - checks.forEach( ( check ) => { - check.getHighlightSelections().forEach( ( selection ) => { - highlightNodes.push.apply( highlightNodes, surfaceView.getDocument().selectNodes( selection.getCoveringRange(), 'branches' ).map( ( spec ) => spec.node ) ); - const selectionView = ve.ce.Selection.static.newFromModel( selection, surfaceView ); - selections.push( selectionView ); - } ); - } ); - // TODO: Make selections clickable when multicheck is enabled - surfaceView.getSelectionManager().drawSelections( - 'editCheck', - selections - ); - surfaceView.setReviewMode( true, highlightNodes ); - }; - - const contextDone = ( responseData, contextData ) => { - if ( !responseData ) { - // this is the back button - return saveProcessDeferred.resolve(); - } - const selectionIndex = checks.indexOf( contextData.action ); - - if ( responseData.action !== 'reject' ) { - mw.notify( ve.msg( 'editcheck-dialog-addref-success-notify' ), { type: 'success' } ); - } else if ( responseData.reason ) { - mw.editcheck.rejections.push( responseData.reason ); - } - // TODO: Move on to the next issue, when multicheck is enabled - // checks = mw.editcheck.editCheckFactory.createAllByListener( 'onBeforeSave', surface.getModel() ); - checks = []; - - if ( checks.length ) { - context.removePersistentSource( 'editCheckReferences' ); - setTimeout( () => { - // timeout needed to wait out the newly added content being focused - surface.getModel().setNullSelection(); - drawSelections( checks ); - setTimeout( () => { - // timeout needed to allow the context to reposition - showCheckContext( checks[ Math.min( selectionIndex, checks.length - 1 ) ] ); - } ); - }, 500 ); - } else { - saveProcessDeferred.resolve( true ); - } - }; - - // eslint-disable-next-line no-inner-declarations - function showCheckContext( check ) { - const fragment = check.fragments[ 0 ]; - - // Select the found content to correctly position the context on desktop - fragment.select(); - - context.addPersistentSource( { - embeddable: false, - data: { - action: check, - fragment: fragment, - callback: contextDone, - saveProcessDeferred: saveProcessDeferred - }, - name: 'editCheckReferences' - } ); - - // Deactivate to prevent selection suppressing mobile context - surface.getView().deactivate(); - - // Once the context is positioned, clear the selection - setTimeout( () => { - surface.getModel().setNullSelection(); - } ); + let $contextContainer, contextPadding; + if ( surface.context.popup ) { + contextPadding = surface.context.popup.containerPadding; + $contextContainer = surface.context.popup.$container; + surface.context.popup.$container = surface.$element; + surface.context.popup.containerPadding = 20; } - drawSelections( checks ); - toolbar.toggle( false ); - target.onContainerScroll(); - saveProcess.next( () => { - showCheckContext( checks[ 0 ] ); + toolbar.toggle( false ); + target.onContainerScroll(); + // surface.executeCommand( 'editCheckDialogBeforeSave' ); + const windowAction = ve.ui.actionFactory.create( 'window', surface, 'check' ); + return windowAction.open( 'editCheckDialog', { listener: 'onBeforeSave', reviewMode: true } ) + .then( ( instance ) => instance.closing ) + .then( ( data ) => { + reviewToolbar.$element.remove(); + toolbar.toggle( true ); + target.toolbar = toolbar; + if ( $contextContainer ) { + surface.context.popup.$container = $contextContainer; + surface.context.popup.containerPadding = contextPadding; + } + // Creating a new PositionedTargetToolbar stole the + // toolbar windowmanagers, so we need to make the + // original toolbar reclaim them: + toolbar.disconnect( target ); + target.setupToolbar( surface ); + target.onContainerScroll(); - return saveProcessDeferred.promise().then( ( data ) => { - context.removePersistentSource( 'editCheckReferences' ); - - surfaceView.getSelectionManager().drawSelections( 'editCheck', [] ); - surfaceView.setReviewMode( false ); - - reviewToolbar.$element.remove(); - toolbar.toggle( true ); - target.toolbar = toolbar; - target.onContainerScroll(); - - // Check the user inserted a citation - if ( data ) { - const delay = ve.createDeferred(); - // If they inserted, wait 2 seconds on desktop before showing save dialog - setTimeout( () => { - ve.track( 'counter.editcheck.preSaveChecksCompleted' ); - delay.resolve(); - }, !OO.ui.isMobile() && data.action !== 'reject' ? 2000 : 0 ); - return delay.promise(); - } else { - ve.track( 'counter.editcheck.preSaveChecksAbandoned' ); - return ve.createDeferred().reject().promise(); - } - } ); + if ( data ) { + const delay = ve.createDeferred(); + // If they inserted, wait 2 seconds on desktop + // before showing save dialog to make sure insertions are finialized + setTimeout( () => { + ve.track( 'counter.editcheck.preSaveChecksCompleted' ); + delay.resolve(); + }, !OO.ui.isMobile() && data.action !== 'reject' ? 2000 : 0 ); + return delay.promise(); + } else { + // closed via "back" or otherwise + ve.track( 'counter.editcheck.preSaveChecksAbandoned' ); + return ve.createDeferred().reject().promise(); + } + } ); } ); } else { // Counterpart to earlier preSaveChecksShown, for use in tracking @@ -265,11 +197,6 @@ if ( mw.config.get( 'wgVisualEditorConfig' ).editCheck || mw.editcheck.ecenable ve.track( 'counter.editcheck.preSaveChecksNotShown' ); } } ); - mw.hook( 've.deactivationComplete' ).add( () => { - if ( saveProcessDeferred ) { - saveProcessDeferred.reject(); - } - } ); } ve.ui.EditCheckBack = function VeUiEditCheckBack() { @@ -286,12 +213,9 @@ ve.ui.EditCheckBack.static.autoAddToGroup = false; ve.ui.EditCheckBack.static.title = OO.ui.deferMsg( 'visualeditor-backbutton-tooltip' ); ve.ui.EditCheckBack.prototype.onSelect = function () { - const context = this.toolbar.getSurface().getContext(); - if ( context.inspector ) { - context.inspector.close(); - } else { - context.items[ 0 ].close(); - } + const surface = this.toolbar.getSurface(); + surface.getContext().hide(); + surface.execute( 'window', 'close', 'editCheckDialog' ); this.setActive( false ); }; ve.ui.EditCheckBack.prototype.onUpdateState = function () { diff --git a/extension.json b/extension.json index 83c1dc617b..3d0e942c9a 100644 --- a/extension.json +++ b/extension.json @@ -136,6 +136,10 @@ "value": false, "description": "Enable experimental Edit Check feature. Can also be enabled using ?ecenable=1." }, + "VisualEditorEditCheckSingleCheckMode": { + "value": true, + "description": "Only allow a single edit check to be surfaced" + }, "VisualEditorEditCheckABTest": { "value": false, "description": "A/B test Edit Check for all users. A/B bucket status will override VisualEditorEditCheck." @@ -647,7 +651,10 @@ "editcheck-dialog-addref-success-notify", "editcheck-dialog-addref-title", "editcheck-dialog-title", - "visualeditor-backbutton-tooltip" + "editcheck-review-title", + "visualeditor-backbutton-tooltip", + "next", + "last" ] }, "ext.visualEditor.core.utils": { diff --git a/includes/Hooks.php b/includes/Hooks.php index f592a0dff5..f335cf7325 100644 --- a/includes/Hooks.php +++ b/includes/Hooks.php @@ -1159,6 +1159,7 @@ class Hooks implements 'useChangeTagging' => $veConfig->get( 'VisualEditorUseChangeTagging' ), 'editCheckTagging' => $veConfig->get( 'VisualEditorEditCheckTagging' ), 'editCheck' => $veConfig->get( 'VisualEditorEditCheck' ), + 'editCheckSingle' => $veConfig->get( 'VisualEditorEditCheckSingleCheckMode' ), 'editCheckABTest' => $veConfig->get( 'VisualEditorEditCheckABTest' ), 'editCheckReliabilityAvailable' => ApiEditCheckReferenceUrl::isAvailable(), 'namespacesWithSubpages' => $namespacesWithSubpagesEnabled,