'use strict';
const midEditListeners = [ 'onDocumentChange', 'onBranchNodeChange' ];
// System messages have no listener. #registerSystemMessage adds them to the 'systemMessages'
// action list, which actionsByListener, #getActions and #removeAction accept as a listener.
const midEditActionLists = [ ...midEditListeners, 'systemMessages' ];
/**
* EditCheck controller
*
* Manages triggering and updating edit checks.
*
* Lifecycle:
*
* - init.js makes one controller for each article target. The controller
* connects to each new surface on surfaceReady, which includes a section
* switch. When the surface is destroyed, it closes its sidebar and clears
* its state.
* - A first refresh runs soon after the controller connects to a surface.
* Actions found in this refresh are not "new", because they are restored
* or are suggestions that the user did not cause. A refresh runs the
* checks of the mid-edit listeners at the same time.
* - Mid-edit, an undo stack change runs the onDocumentChange checks. A
* selection in a different branch node runs the onBranchNodeChange checks,
* if the document changed after their last run. updateForListener keeps an
* existing action when it finds an equal one, so that the action keeps its
* state. The exception is a check that is equal to a suggestion: the check
* replaces the suggestion. updateForListener emits actionsUpdatedProgress
* for each action when it arrives. Until all checks for the listener are
* finished, a new action is pending. When they are finished,
* updateForListener emits actionsUpdated if the list of actions changed,
* or if an action was replaced. If a newer run for the same listener
* started before this, updateForListener ignores the results of the older
* run and does not emit actionsUpdated for it.
* - Before save, the onBeforeSave checks run and inBeforeSave is true.
* getActions then gives only the pre-save actions.
*
* Dialogs:
*
* - Mid-edit, the controller opens one sidebar with showSidebar, when the
* first action that it can show arrives: sidebarEditCheckDialog on
* desktop, or gutterSidebarEditCheckDialog on mobile. On mobile, a gutter
* icon opens mobileEditCheckDialog, with a scope that limits it to the
* actions of that part of the page. A click on a different icon while it
* is open replaces its scope.
* - Before save, the controller opens fixedEditCheckDialog if there are
* pre-save actions.
* - A dialog reads its actions from getDisplayActions, in its setup and on
* each actionsUpdated, and keeps only the actions in its scope.
* getDisplayActions includes the pending actions. The window
* manager runs the setup some time after the open request, and a dialog
* gets no events before its setup. The read in setup includes the updates
* that occur while the dialog opens. A showSidebar call while the sidebar
* opens adds its new actions to the data that the setup reads.
* ensureActionIsShown waits until the sidebar is open before it focuses
* an action.
* - Each dialog ignores action updates from the mode that it is not in:
* pre-save or mid-edit.
*
* @class EditCheckController
* @constructor
* @mixes OO.EventEmitter
* @param {ve.init.mw.Target} target The VisualEditor target
* @param {Object} config
* @param {boolean} config.suggestionsModeAvailable Suggestions mode is available
*/
function Controller( target, config ) {
// Mixin constructors
OO.EventEmitter.call( this );
this.target = target;
// Suggestion mode is available, and the suggestion mode toggle is visible in the toolbar
this.suggestionsModeAvailable = config.suggestionsModeAvailable;
// Suggestions are currently visible, toggled by the toolbar tool
this.suggestionsVisible = this.suggestionsModeAvailable && (
!!ve.userConfig( 'visualeditor-editcheck-suggestions-toggle' ) ||
// Preference only applies to desktop for now
OO.ui.isMobile()
);
// Suppress suggestions without affecting user config or toolbar state, used by external tools
this.suppressSuggestions = false;
// These are not in clearState as we may want them to persist when switching sections (surface reload)
this.lastAvailableSuggestionCount = 0;
this.clearState();
const teardownCheck = () => !!this.surface;
this.onUndoStackChangeDebounced = ve.debounceWithTest( teardownCheck, this.onUndoStackChange.bind( this ), 100 );
this.onPositionDebounced = ve.debounceWithTest( teardownCheck, this.onPosition.bind( this ), 100 );
this.onSelectDebounced = ve.debounceWithTest( teardownCheck, this.onSelect.bind( this ), 100 );
this.onContextChangeDebounced = ve.debounceWithTest( teardownCheck, this.onContextChange.bind( this ), 100 );
this.updatePositionsDebounced = ve.debounceWithTest( teardownCheck, this.updatePositions.bind( this ), 50 );
this.updateSuggestionCountDebounced = ve.debounceWithTest( teardownCheck, this.updateSuggestionCount.bind( this ), 500 );
this.target.connect( this, {
virtualKeyboardChange: 'onVirtualKeyboardChange'
} );
this.scrollActionIntoViewDebounced = ve.debounceWithTest( teardownCheck, this.scrollActionIntoView.bind( this ) );
this.perf = new mw.editcheck.EditCheckPerformance( this );
}
/* Inheritance */
OO.mixinClass( Controller, OO.EventEmitter );
/* Events */
/**
* Actions for a given listener are updated
*
* @event EditCheckController#actionsUpdated
* @param {string} listener The action list that changed: a listener type (e.g. 'onBeforeSave'),
* or 'systemMessages' for the actions from #registerSystemMessage
* @param {mw.editcheck.EditCheckAction[]} actions All current actions
* @param {mw.editcheck.EditCheckAction[]} newActions Actions newly added
* @param {mw.editcheck.EditCheckAction[]} discardedActions Actions newly removed
* @param {boolean} rejected The update was due to a user rejecting/dismissing a check
*/
/**
* Progress while actions for a given listener are being updated
*
* @event EditCheckController#actionsUpdatedProgress
* @param {string} listener The listener type (e.g. 'onBeforeSave')
* @param {mw.editcheck.EditCheckAction} action
* @param {mw.editcheck.EditCheckAction} oldAction previously present equivalent action
*/
/**
* An action is focused
*
* @event EditCheckController#focusAction
* @param {mw.editcheck.EditCheckAction} action Action
* @param {number} index Index of the action in #getActions
* @param {boolean} scrollTo Scroll the action's selection into view
*/
/**
* Actions have been redrawn or repositioned
*
* @event EditCheckController#position
*/
/**
* The selected branch node has changed
*
* @event EditCheckController#branchNodeChange
* @param {ve.dm.BranchNode} branchNode
*/
/* Methods */
/**
* Reset controller state (on init or teardown)
*/
Controller.prototype.clearState = function () {
if ( this.systemMessages ) {
[ ...this.systemMessages.keys() ].forEach( ( action ) => this.forgetSystemMessage( action ) );
}
this.actionsByListener = {};
this.surface = null;
this.inBeforeSave = false;
this.branchNode = null;
this.focusedAction = null;
this.inSetup = null;
this.ignoreNextSelectionChange = null;
this.taggedFragments = {};
this.taggedIds = {};
this.ephemeralTags = [];
// State of each system message action from #registerSystemMessage
this.systemMessages = new Map();
this.lastBranchNodeChangeHistoryPointer = null;
this.refreshDeferred = null;
this.sidebarOpeningPromise = null;
this.sidebarOpeningNewActions = null;
this.runsByListener = {};
this.pendingActionsByListener = {};
};
/**
* Set up controller
*/
Controller.prototype.setup = function () {
const target = this.target;
target.on( 'surfaceReady', () => {
this.surface = target.getSurface();
if ( this.surface.getMode() !== 'visual' ) {
// Some checks will entirely work in source mode for most cases.
// But others will fail spectacularly -- e.g. reference check
// isn't aware of <ref> tags and so will suggest that all content
// has references added. As such, disable in source mode for now.
return;
}
// TODO: Consider if suggestions are possible as well
if ( !this.editChecksArePossible() ) {
return;
}
// Ideally this would happen slightly earlier:
$( document.documentElement ).addClass( 've-editcheck-available' );
// Adding the class can cause large layout changes (e.g. hiding Vector
// side panels), so emit a window resize event to notify any components
// that may be affected, e.g. the VE toolbar
window.dispatchEvent( new Event( 'resize' ) );
this.surface.getView().connect( this, {
position: 'onPositionDebounced'
} );
this.surface.getModel().connect( this, {
// The less-frequent undoStackChange is used for updating actions
undoStackChange: 'onUndoStackChangeDebounced',
// documentUpdate is used for updating highlights
documentUpdate: 'onDocumentUpdate',
select: 'onSelectDebounced',
contextChange: 'onContextChangeDebounced'
} );
this.surface.getSidebarDialogs().connect( this, {
opening: 'onSidebarDialogsOpeningOrClosing',
closing: 'onSidebarDialogsOpeningOrClosing'
} );
this.on( 'branchNodeChange', this.onBranchNodeChange, null, this );
this.on( 'actionsUpdated', this.onActionsUpdated, null, this );
// Run on load (e.g. recovering from auto-save)
this.inSetup = true;
setTimeout( () => this.refresh().always( () => {
this.inSetup = null;
} ), 100 );
this.surface.on( 'destroy', () => {
this.perf.recordTypingLagSummary();
this.off( 'actionsUpdated' );
const win = this.surface.getSidebarDialogs().getCurrentWindow();
if ( win ) {
win.close();
}
this.clearState();
$( document.documentElement ).removeClass( 've-editcheck-available' );
window.dispatchEvent( new Event( 'resize' ) );
} );
}, null, this );
this.setupPreSaveProcess();
};
/**
* Get the current VE target
*
* @return {ve.init.mw.Target}
*/
Controller.prototype.getTarget = function () {
return this.target;
};
/**
* Handle sidebar dialog open/close events
*
* Transition skin/VE components around to make room for sidebar
*
* @param {OO.ui.Window} win The window instance
* @param {jQuery.Promise} openingOrClosing Promise that resolves when closing finishes
*/
Controller.prototype.onSidebarDialogsOpeningOrClosing = function ( win, openingOrClosing ) {
if ( win.constructor.static.name !== 'sidebarEditCheckDialog' ) {
return;
}
const isOpening = !win.isOpened();
// Wait for sidebar to render before applying CSS which starts transitions
requestAnimationFrame( () => {
$( document.documentElement ).toggleClass( 've-editcheck-enabled', isOpening );
} );
if ( isOpening ) {
mw.hook( 've.hideVectorColumns' ).fire();
} else {
openingOrClosing.then( () => {
mw.hook( 've.restoreVectorColumns' ).fire();
} );
}
// Adjust toolbar position after animation ends
setTimeout( () => {
// Check the toolbar still exists (i.e. we haven't closed the editor)
if ( this.target.toolbar ) {
this.target.toolbar.onWindowResize();
}
}, OO.ui.theme.getDialogTransitionDuration() );
};
/**
* Check if any edit checks could be run for the current user/context
*
* @return {boolean}
*/
Controller.prototype.editChecksArePossible = function () {
if ( mw.editcheck.suggestionsModeAvailable ) {
// Suggestions override user checks so assume something can be shown
return true;
}
return [ 'onBeforeSave', 'onDocumentChange', 'onBranchNodeChange' ].some(
( listener ) => mw.editcheck.editCheckFactory.getNamesByListener( listener ).some(
( checkName ) => this.canBeShown( checkName )
)
);
};
/**
* Check if a given edit check could be run for the current user/context
*
* @param {string} checkName Check name
* @param {boolean} [suggestion=false] Whether we are checking for suggestion mode
* @return {boolean}
*/
Controller.prototype.canBeShown = function ( checkName, suggestion = false ) {
const check = mw.editcheck.editCheckFactory.create( checkName, this );
try {
return check.canBeShown( this.surface.getModel().getDocument(), suggestion );
} catch ( e ) {
mw.log.error( `Error checking canBeShown for ${ checkName }`, e );
return false;
}
};
/**
* Update position of edit check highlights
*
* @fires EditCheckController#position
*/
Controller.prototype.updatePositions = function () {
this.drawSelections();
this.emit( 'position' );
};
/**
* Update edit check list
*
* @fires EditCheckController#actionsUpdated
* @param {boolean} useCache Whether to piggyback onto an existing refresh if one is ongoing
* @return {Promise<mw.editcheck.EditCheckAction[]>} An updated set of
* actions. This promise will resolve *after* any actionsUpdated events are
* fired.
*/
Controller.prototype.refresh = function ( useCache ) {
if ( this.refreshDeferred && useCache ) {
return this.refreshDeferred.promise();
}
const deferred = ve.createDeferred();
deferred.always( () => {
if ( this.refreshDeferred === deferred ) {
this.refreshDeferred = null;
}
} );
this.refreshDeferred = deferred;
if ( this.target.deactivating || !this.target.active ) {
return deferred.reject().promise();
}
if ( this.inBeforeSave ) {
// These shouldn't be recalculated
const actions = this.getActions();
this.emit( 'actionsUpdated', 'onBeforeSave', actions, [], [], false );
return deferred.resolve( actions ).promise();
} else {
// Run the listeners at the same time, so that a slow check for one
// listener does not delay the checks for the other. Start each in its
// own idle callback, so that their synchronous work is not one long
// task. The timeout limits the delay when the page is busy.
const runs = midEditListeners.map( ( listener ) => new Promise( ( resolve ) => {
mw.requestIdleCallback( () => {
try {
resolve( this.updateForListener( listener, true ) );
} catch ( error ) {
mw.log.error( 'Could not update for listener: ' + listener, error );
resolve( [] );
}
}, { timeout: 300 } );
} ) );
Promise.all( runs ).then( () => {
deferred.resolve( this.getActions() );
} );
return deferred.promise();
}
};
/**
* Wait for the sidebar to be shown, if it is being opened
*
* @return {jQuery.Promise}
*/
Controller.prototype.whenSidebarShown = function () {
return this.sidebarOpeningPromise || ve.createDeferred().resolve().promise();
};
/**
* Toggle whether suggestions are shown to the user.
*/
Controller.prototype.toggleSuggestionsVisible = function () {
if ( !this.suggestionsModeAvailable ) {
return;
}
this.suggestionsVisible = !this.suggestionsVisible;
if ( !!ve.userConfig( 'visualeditor-editcheck-suggestions-toggle' ) !== this.suggestionsVisible ) {
ve.userConfig( 'visualeditor-editcheck-suggestions-toggle', this.suggestionsVisible );
}
mw.notify(
ve.msg( this.suggestionsVisible ?
( this.lastAvailableSuggestionCount > 0 ? 'editcheck-suggestions-turned-on' : 'editcheck-suggestions-none' ) :
'editcheck-suggestions-turned-off'
),
{ tag: 'editcheck-suggestions-toggle', type: 'notice' }
);
this.actionsByListener = {};
// Treat this refresh as being as if we were in initial setup -- we don't
// want the "new" suggestions to be focused.
this.inSetup = true;
this.refresh().always( () => {
this.inSetup = null;
} );
};
/**
* Suppress suggestions without affecting user preferences
*
* Suggestions will still continue to be generated and cached, just not displayed.
* For use by external tools.
*
* @param {boolean} suppress if true, does not display any suggestions
*/
Controller.prototype.suppressSuggestionDisplay = function ( suppress ) {
if ( this.suppressSuggestions === suppress ) {
return;
}
this.suppressSuggestions = suppress;
this.refresh();
};
/**
* Update the suggestion count shown on the toolbar tool
*
* @param {number} count The number of suggestions
*/
Controller.prototype.updateSuggestionCount = function ( count ) {
const suggestionsModeTool = this.target.getToolbar().tools.editCheckSuggestions;
if ( suggestionsModeTool ) {
suggestionsModeTool.$icon.attr(
'data-count',
ve.msg( 'editcheck-toolbar-suggestions-count', Math.min( 100, count ) )
);
}
};
/**
* Fires all edit checks associated with a given listener.
*
* Actions are created anew for every run, but we want continuity for certain state changes. We therefore match them up
* to existing actions by checking for equality, ie, the same constructor and same ID or fragments.
*
* We return a promise so that UI actions such as opening the pre-save dialog
* do not occur until checks have completed.
*
* @param {string} listener e.g. onBeforeSave, onDocumentChange, onBranchNodeChange
* @param {boolean} fromRefresh Update comes from a manual refresh, not a real event
* @return {Promise<mw.editcheck.EditCheckAction[]>} An updated set of actions.
* @fires EditCheckController#actionsUpdated
* @fires EditCheckController#actionsUpdatedProgress
*/
Controller.prototype.updateForListener = function ( listener, fromRefresh ) {
if ( !this.surface ) {
// The controller has been destroyed while this was waiting to be called;
// just abandon early with a claim there were no checks found, in case any
// listeners try to do something.
return Promise.resolve( [] );
}
if ( this.surface.getModel().isStaging() ) {
return Promise.resolve( this.getActions( listener ) );
}
const run = {};
this.runsByListener[ listener ] = run;
// Actions that arrive before all checks for this listener finish
const pending = [];
this.pendingActionsByListener[ listener ] = pending;
const clearPending = () => {
if ( this.pendingActionsByListener[ listener ] === pending ) {
delete this.pendingActionsByListener[ listener ];
}
};
const onProgress = ( action ) => {
const existing = this.getActions( listener );
const oldAction = existing.find( ( existingAction ) => action.equals( existingAction ) );
if ( oldAction && !( oldAction.isSuggestion() && !action.isSuggestion() ) ) {
// Let a new non-suggestion take over from an old suggestion
if ( oldAction.updateFrom( action ) ) {
oldAction.refreshWidget();
}
action = oldAction;
}
if ( this.runsByListener[ listener ] !== run ) {
// A newer run for this listener replaced this run
return;
}
if ( !existing.includes( action ) ) {
pending.push( action );
// The user can see and use a pending action before the run finishes
this.trackAction( action );
}
this.emit( 'actionsUpdatedProgress', listener, action, oldAction );
this.onActionStreamed( listener );
};
let actionsPromise;
// Create all actions for this listener
if ( this.suggestionsModeAvailable && !this.inBeforeSave ) {
// eslint-disable-next-line no-jquery/no-when
actionsPromise = $.when(
mw.editcheck.editCheckFactory.createAllActionsByListener( this, listener, this.surface.getModel(), true, onProgress ),
mw.editcheck.editCheckFactory.createAllActionsByListener( this, listener, this.surface.getModel(), false, onProgress )
).then( ( suggestionActions, checkActions ) => [
...checkActions,
// Discard any suggestions that have an equivalent non-suggestion
...suggestionActions.filter( ( suggestion ) => !checkActions.find( ( action ) => action.equals( suggestion, true ) ) )
] );
} else {
actionsPromise = mw.editcheck.editCheckFactory.createAllActionsByListener( this, listener, this.surface.getModel(), false, onProgress );
}
actionsPromise = actionsPromise
.then( ( actionsFromListener ) => {
clearPending();
if ( this.runsByListener[ listener ] !== run ) {
// A newer run for this listener started, or the surface was
// destroyed. The results of this run are out of date.
return this.getActions( listener );
}
// Get the existing actions for this listener
const existing = this.getActions( listener );
// Try to match each new action to an existing one (to preserve state)
const replacements = [];
const actions = actionsFromListener.map( ( action ) => {
const oldAction = existing.find( ( existingAction ) => action.equals( existingAction ) );
if ( oldAction && !( oldAction.isSuggestion() && !action.isSuggestion() ) ) {
return oldAction;
}
if ( oldAction ) {
// Let a new non-suggestion take over from an old suggestion.
// It is not new to the user, but the dialogs must draw it again.
replacements.push( action );
if ( this.focusedAction === oldAction ) {
this.focusedAction = action;
}
}
return action;
} );
// A real event checks the current text again, so the text is no
// longer stale. The 'revising' tag stays until the selection
// leaves the action, and any run can find that it was cleared.
let staleUpdated = false;
actions.forEach( ( action ) => {
if ( action.isStale() && action.updateStale( !fromRefresh ) ) {
staleUpdated = true;
}
} );
// Update the actions for this listener
this.actionsByListener[ listener ] = actions;
let newActions = actions.filter( ( action ) => existing.every( ( oldAction ) => !action.equals( oldAction ) ) );
const discardedActions = existing.filter( ( action ) => actions.every( ( newAction ) => !action.equals( newAction ) ) );
[ ...newActions, ...replacements ]
// Pending actions are tracked from when they arrive
.filter( ( action ) => !pending.includes( action ) )
.forEach( ( action ) => this.trackAction( action ) );
// If the actions list changed, update
if (
fromRefresh || staleUpdated || actions.length !== existing.length ||
newActions.length || discardedActions.length || replacements.length
) {
if ( this.inSetup ) {
// Any actions that are present during initial setup
// shouldn't be treated as being "new". They're either
// restored from a saved session, or are suggestions, and
// in either case we don't want them treated as if the
// user just caused them.
newActions = [];
}
if ( this.suppressSuggestions ) {
newActions = newActions.filter( ( action ) => !action.isSuggestion() );
}
// TODO: We need to consider a consistency check here as the document state may have changed since the
// action within the promise was created
// Notify listeners that actions have been updated
this.emit( 'actionsUpdated', listener, this.getActions(), newActions, discardedActions, false );
}
// Return the updated actions
return actions;
} ).catch( ( error ) => {
mw.log.error( 'Could not update for listener: ' + listener, error );
return [];
} );
actionsPromise.then( clearPending, clearPending );
return actionsPromise;
};
/**
* Record when an action is shown, seen or used
*
* @param {mw.editcheck.EditCheckAction} action
*/
Controller.prototype.trackAction = function ( action ) {
action.once( 'shown', this.onActionSeenOrShown.bind( this, action, 'shown' ) );
action.once( 'seen', this.onActionSeenOrShown.bind( this, action, 'seen' ) );
action.on( 'act', this.onActionAct, [ action ], this );
action.on( 'complete', this.onActionCompleted, [ action ], this );
};
/**
* Get the current actions, and the mid-edit actions that are pending
*
* A pending action arrived before all checks for its listener finished. As
* when the checks finish, a check replaces an equal suggestion, and other
* equal actions are shown once.
*
* @return {mw.editcheck.EditCheckAction[]} Actions
*/
Controller.prototype.getActionsWithPending = function () {
const actions = this.getActions();
if ( this.inBeforeSave ) {
return actions;
}
midEditListeners.forEach( ( listener ) => {
( this.pendingActionsByListener[ listener ] || [] ).forEach( ( action ) => {
if ( this.suppressSuggestions && action.isSuggestion() ) {
return;
}
const index = actions.findIndex( ( listAction ) => listAction.equals( action ) );
if ( index === -1 ) {
actions.push( action );
} else if ( actions[ index ].isSuggestion() && !action.isSuggestion() ) {
actions[ index ] = action;
}
} );
} );
actions.sort( mw.editcheck.EditCheckAction.static.compareStarts );
return actions;
};
/**
* Get the actions that a dialog can show
*
* @return {mw.editcheck.EditCheckAction[]} Actions
*/
Controller.prototype.getDisplayActions = function () {
return this.filterActionsForDisplay( this.getActionsWithPending() );
};
/**
* Handle an action that arrives before all checks for its listener finish
*
* Show it now, so that the user does not wait for the slowest check.
*
* @param {string} listener
*/
Controller.prototype.onActionStreamed = function ( listener ) {
if ( listener === 'onBeforeSave' || this.inBeforeSave || !this.surface ) {
return;
}
// The suggestion count and indicators wait for all checks to finish.
// Until then, the list still has the old actions that the run removes.
if ( this.getDisplayActions().length ) {
// New actions get the focus when all checks finish, after the rules
// for setup and suppressed suggestions apply
this.showSidebar( [] );
}
};
/**
* Filter actions for display based on current settings
*
* @param {mw.editcheck.EditCheckAction[]} actions Actions to filter
* @return {mw.editcheck.EditCheckAction[]} Filtered actions
*/
Controller.prototype.filterActionsForDisplay = function ( actions ) {
if ( !this.suggestionsVisible || this.suppressSuggestions ) {
return actions.filter( ( action ) => !action.isSuggestion() );
}
return actions;
};
/**
* Remove an edit check action
*
* @param {string} listener Listener which triggered the action
* @param {mw.editcheck.EditCheckAction} action Action to remove
* @param {boolean} rejected The action was rejected
* @fires EditCheckController#actionsUpdated
*/
Controller.prototype.removeAction = function ( listener, action, rejected ) {
const actions = this.actionsByListener[ listener ];
if ( !actions || actions.length === 0 ) {
return;
}
const index = actions.indexOf( action );
if ( index === -1 ) {
return;
}
const removed = actions.splice( index, 1 );
this.emit( 'actionsUpdated', listener, this.getActions(), [], removed, rejected );
};
/**
* Trigger a focus state for a given action
*
* Will emit a focusAction event if the focused action changed or if scrolling
* was requested.
*
* @param {mw.editcheck.EditCheckAction} action Action to focus
* @param {boolean} [scrollTo] Scroll action's selection into view
* @param {Object} [scrollConfig] Configuration for scrolling
* @return {jQuery.Promise} Promise resolved when scrolling is complete
* @fires EditCheckController#focusAction
* @fires EditCheckController#position
*/
Controller.prototype.focusAction = function ( action, scrollTo, scrollConfig ) {
if ( !scrollTo && action === this.focusedAction ) {
// Don't emit unnecessary events if there is no change or scroll
return ve.createDeferred().resolve().promise();
}
this.focusedAction = action;
let scrollPromise;
if ( scrollTo ) {
scrollPromise = this.scrollActionIntoView( action, scrollConfig );
}
this.emit( 'focusAction', action, this.getActions().indexOf( action ), scrollTo );
this.updatePositionsDebounced();
return scrollPromise || ve.createDeferred().resolve().promise();
};
/**
* Make sure an action is visible to the user
*
* This will scroll the action into view and make sure its widget is expanded
* so the contents can be seen.
*
* @param {mw.editcheck.EditCheckAction} action Action to focus
* @param {Object|boolean} [scrollConfig] Configuration for scrolling or (deprecated) a boolean to set alignToTop
*/
Controller.prototype.ensureActionIsShown = function ( action, scrollConfig ) {
if ( scrollConfig === true ) {
scrollConfig = { alignToTop: true };
}
// If this is called directly from refresh then the sidebar was opened
// by an actionsUpdated that *immediately* precedes this call, so it
// can still be opening. A dialog gets no events before its setup.
this.whenSidebarShown().then( () => {
if ( !this.surface ) {
// The surface was destroyed while we waited
return;
}
if ( !OO.ui.isMobile() ) {
this.focusAction( action, true, scrollConfig );
return;
}
const currentWindow = this.surface.getSidebarDialogs().getCurrentWindow();
if ( !currentWindow || currentWindow.constructor.static.name !== 'gutterSidebarEditCheckDialog' ) {
return;
}
currentWindow.whenActionsRendered().then( () => {
// This will ultimately focus the action and scroll it into view as well:
currentWindow.showDialogWithAction( action, ve.extendObject( { alignToTop: true }, scrollConfig ) );
} );
} );
};
/**
* Scroll to the action nearest the user's current position
*
* @param {string} [direction] If actions exist both above and below, it will scroll in this direction
* @return {boolean} An action was found and focused
*/
Controller.prototype.scrollToNearestAction = function ( direction ) {
const currentWindow = this.surface.getSidebarDialogs().getCurrentWindow();
return !!( currentWindow && currentWindow.scrollToNearestAction && currentWindow.scrollToNearestAction( direction ) );
};
/**
* Get actions by listener
*
* If no listener is specified, then get all actions relevant to the current moment, i.e.:
* - During beforeSave, get onBeforeSave listeners
* - Otherwise, get all mid-edit listeners
*
* @param {string} [listener] The listener; if omitted, get all relevant actions
* @return {mw.editcheck.EditCheckAction[]} Actions
*/
Controller.prototype.getActions = function ( listener ) {
if ( listener ) {
let lsActions = this.actionsByListener[ listener ] || [];
if ( this.suppressSuggestions ) {
lsActions = lsActions.filter( ( action ) => !action.isSuggestion() );
}
return lsActions;
}
const listeners = this.inBeforeSave ? [ 'onBeforeSave' ] : midEditActionLists;
let actions = [].concat( ...listeners.map( ( lr ) => this.actionsByListener[ lr ] || [] ) );
if ( this.suppressSuggestions ) {
actions = actions.filter( ( action ) => !action.isSuggestion() );
}
actions.sort( mw.editcheck.EditCheckAction.static.compareStarts );
return actions;
};
/**
* Handle virtual keyboard change events from the target
*/
Controller.prototype.onVirtualKeyboardChange = function () {
// On mobile we want to close the drawer if the keyboard is shown
if ( this.surface && OO.ui.isMobile() && !this.inBeforeSave && this.target.isVirtualKeyboardOpen() ) {
this.closeDialog( 'mobile-keyboard' );
}
};
/**
* Handle select events from the surface model
*
* @param {ve.dm.Selection} selection New selection
*/
Controller.prototype.onSelect = function () {
if ( this.ignoreNextSelectionChange ) {
this.ignoreNextSelectionChange = null;
return;
}
this.clearEphemeralTagsForSelection();
this.emitBranchNodeChangeIfNeeded();
if ( !OO.ui.isMobile() ) {
this.focusActionForSelection();
}
};
/**
* Register an ephemeral tag, to be cleared once the selection leaves its range
*
* @param {string} name Tag namespace (check name)
* @param {string} tag Tag
* @param {ve.dm.SurfaceFragment} fragment The tagged fragment, as stored in taggedFragments
*/
Controller.prototype.registerEphemeralTag = function ( name, tag, fragment ) {
this.ephemeralTags.push( { name, tag, fragment } );
};
/**
* Register a system message tied to a fragment
*
* Shown as an EditCheckAction card at a resolved action's location.
*
* The action is added at once, so that a dialog whose last action completed
* has the message to show, and does not close.
*
* @param {ve.dm.SurfaceFragment} fragment Fragment where the resolved action was
* @param {Object} config EditCheckAction configuration
* @param {string} [config.messageType='success'] Name of a static.messageTypes entry to use as a base
* @param {boolean} [config.dismissOnOtherFocus] If this suggestion card should be dismissed as soon as user moves on
* to a different check or suggestion
* @return {Promise} Resolves when the action is announced as new
* @fires EditCheckController#actionsUpdated
*/
Controller.prototype.registerSystemMessage = function ( fragment, config ) {
const messageTypes = mw.editcheck.SystemMessageEditCheck.static.messageTypes;
const actionConfig = ve.extendObject( {}, messageTypes[ config.messageType ] || messageTypes.success, config );
const action = new mw.editcheck.EditCheckAction( ve.extendObject( {}, actionConfig, {
check: mw.editcheck.editCheckFactory.create( mw.editcheck.SystemMessageEditCheck.static.name, this ),
fragments: [ fragment ]
} ) );
const message = { originalData: fragment.getData() };
this.systemMessages.set( action, message );
this.actionsByListener.systemMessages = ( this.actionsByListener.systemMessages || [] ).concat( action );
this.trackAction( action );
if ( actionConfig.dismissOnOtherFocus ) {
message.onOtherFocus = ( focusedAction ) => {
if ( focusedAction && focusedAction !== action ) {
this.removeSystemMessage( action );
}
};
this.on( 'focusAction', message.onOtherFocus );
}
// Auto-dismiss after the specified time
// (if not specified, this message type stays until the user acts on it or navigates away)
if ( actionConfig.autoDismiss ) {
message.autoDismissTimeout = setTimeout( () => this.removeSystemMessage( action ), actionConfig.autoDismiss );
}
// This can occur in an actionsUpdated handler, so do not emit the event again until it is finished
return Promise.resolve().then( () => {
if ( this.systemMessages.has( action ) ) {
this.emit( 'actionsUpdated', 'systemMessages', this.getActions(), [ action ], [], false );
}
} );
};
/**
* Remove a system message and its action
*
* @param {mw.editcheck.EditCheckAction} action Action of the system message
*/
Controller.prototype.removeSystemMessage = function ( action ) {
if ( this.forgetSystemMessage( action ) ) {
this.removeAction( 'systemMessages', action, false );
}
};
/**
* Stop the timeout and the focus handler of a system message, and forget its state
*
* @private
* @param {mw.editcheck.EditCheckAction} action Action of the system message
* @return {boolean} The system message was registered
*/
Controller.prototype.forgetSystemMessage = function ( action ) {
const message = this.systemMessages.get( action );
if ( !message ) {
// Already removed, e.g. by the user or because it was stale
return false;
}
this.systemMessages.delete( action );
clearTimeout( message.autoDismissTimeout );
if ( message.onOtherFocus ) {
this.off( 'focusAction', message.onOtherFocus );
}
return true;
};
/**
* Remove any system messages whose fragments changed, e.g. by an undo
*/
Controller.prototype.dropStaleSystemMessages = function () {
[ ...this.systemMessages ]
.filter( ( [ action, message ] ) => !ve.compare( action.fragments[ 0 ].getData(), message.originalData ) )
.forEach( ( [ action ] ) => this.removeSystemMessage( action ) );
};
/**
* Stop clearing a tag when the selection leaves its range
*
* @param {ve.dm.SurfaceFragment} fragment The tagged fragment, as given to #registerEphemeralTag
*/
Controller.prototype.unregisterEphemeralTag = function ( fragment ) {
this.ephemeralTags = this.ephemeralTags.filter( ( entry ) => entry.fragment !== fragment );
};
/**
* Clear any ephemeral tags whose range the selection has left, and refresh if so
*/
Controller.prototype.clearEphemeralTagsForSelection = function () {
// Debounced via onSelect; may fire after teardown.
if ( !this.surface || !this.ephemeralTags.length ) {
return;
}
const covering = this.surface.getModel().getSelection().getCoveringRange();
let cleared = false;
this.ephemeralTags = this.ephemeralTags.filter( ( entry ) => {
const range = entry.fragment.getSelection().getCoveringRange();
if ( range && covering && range.containsRange( covering ) ) {
return true;
}
const tagged = this.taggedFragments[ entry.name ] && this.taggedFragments[ entry.name ][ entry.tag ];
const index = tagged ? tagged.indexOf( entry.fragment ) : -1;
if ( index !== -1 ) {
tagged.splice( index, 1 );
}
cleared = true;
return false;
} );
if ( cleared ) {
this.refresh();
}
};
/**
* Update actions based on the current selection
*
* @fires EditCheckController#actionsUpdated
* @fires EditCheckController#focusAction
*/
Controller.prototype.focusActionForSelection = function () {
if ( !this.surface ) {
// This is debounced, and could potentially be called after teardown
return;
}
if ( this.surface.getView().reviewMode ) {
// In review mode the selection and display of checks is being managed by the dialog
return;
}
const selection = this.surface.getModel().getSelection();
const actions = this.getActions();
if ( actions.length === 0 || selection.isNull() ) {
// Nothing to do
return;
}
// Prefer a system message over whatever it's following up on when both overlap the same selection
const preferAction = ( candidates ) => candidates.find( ( action ) => action.check.isSystemCheck() ) ||
candidates[ candidates.length - 1 ];
// First check if the selection matches any action's #getFocusSelection as this
// is more specific than highlights.
const focusSelectionActions = actions.filter(
( action ) => action.getFocusSelection().getCoveringRange().containsRange( selection.getCoveringRange() )
);
if ( focusSelectionActions.length > 0 ) {
// Focus the last action returned (among non-system messages), because it should be the most-specific
this.focusAction( preferAction( focusSelectionActions ), false );
return;
}
const highlightSelectionsActions = actions.filter(
( action ) => action.getHighlightSelections().some(
( highlightSelection ) => highlightSelection.getCoveringRange().containsRange( selection.getCoveringRange() ) ) );
if ( highlightSelectionsActions.length > 0 ) {
this.focusAction( preferAction( highlightSelectionsActions ), false );
return;
}
};
/**
* Whether to ignore the next select event that is received
*
* @param {boolean} [ignore=true]
*/
Controller.prototype.setIgnoreNextSelectionChange = function ( ignore = true ) {
this.ignoreNextSelectionChange = ignore;
};
/**
* Handle contextChange events from the surface model
*/
Controller.prototype.onContextChange = function () {
if ( OO.ui.isMobile() && this.surface.getContext().isVisible() ) {
if ( !this.inBeforeSave ) {
// The context overlaps the drawer on mobile, so we should get rid of the drawer
this.closeDialog( 'context' );
} else {
// We still want to hide the context, just not close the dialog
this.surface.getModel().setNullSelection();
}
}
};
/**
* Handle position events from the surface view
*
* @param {boolean} passive Event is passive (don't scroll)
*/
Controller.prototype.onPosition = function ( passive ) {
// Layout changed, so rects are stale; core's SelectionManager redraws on this event for the
// same reason. Without this, actions found while the surface had no layout box are never
// drawn. (E.g. while the mobile anonwarning was covering the surface.)
this.updatePositionsDebounced();
if ( !passive && this.getActions().length && this.focusedAction && this.surface.getView().reviewMode ) {
this.scrollActionIntoViewDebounced( this.focusedAction, { alignToTop: !OO.ui.isMobile() } );
}
};
/**
* Handle changes to the model's undo stack
*
* This less-frequent event is used for updating actions.
*/
Controller.prototype.onUndoStackChange = function () {
if ( !this.inBeforeSave ) {
this.dropStaleSystemMessages();
this.updateForListener( 'onDocumentChange' );
}
};
/**
* Handle changes to the document model
*
* This event is only used for updating highlights.
*/
Controller.prototype.onDocumentUpdate = function () {
this.updatePositionsDebounced();
};
/**
* Handle changes to the selection moving between branch nodes
*/
Controller.prototype.onBranchNodeChange = function () {
if ( !this.surface ) {
// This is debounced, and could potentially be called after teardown
return;
}
if ( !this.inBeforeSave ) {
const historyPointer = this.surface.getModel().getDocument().getCompleteHistoryLength();
if ( this.lastBranchNodeChangeHistoryPointer === historyPointer ) {
return;
}
this.updateForListener( 'onBranchNodeChange' ).then( () => {
if ( this.surface ) {
this.lastBranchNodeChangeHistoryPointer = historyPointer;
}
} );
}
};
/**
* Handler when 'actionsUpdated' fires.
*
* Updates gutter and highlights when the action list has changed.
* Displays the edit check dialog if it is not already on screen.
*
* @listens EditCheckController#actionsUpdated
* @param {string} listener e.g. onBeforeSave, onDocumentChange, onBranchNodeChange
* @param {mw.editcheck.EditCheckAction[]} actions
* @param {mw.editcheck.EditCheckAction[]} newActions
* @param {mw.editcheck.EditCheckAction[]} discardedActions
*/
Controller.prototype.onActionsUpdated = function ( listener, actions, newActions, discardedActions ) {
// Do we need to redraw anything?
if ( newActions.length || discardedActions.length ) {
if ( this.focusedAction && discardedActions.includes( this.focusedAction ) ) {
this.focusedAction = null;
}
this.updatePositionsDebounced();
}
// Let actions know they've been discarded
for ( const action of discardedActions ) {
action.discarded();
}
// Do we need to show mid-edit actions?
if ( listener === 'onBeforeSave' ) {
return;
}
this.updateSuggestionIndicators( actions );
const visibleNewActions = this.filterActionsForDisplay( newActions );
if ( !this.filterActionsForDisplay( actions ).length ) {
return;
}
this.showSidebar( visibleNewActions ).then( () => {
if ( visibleNewActions.length ) {
// Check if any new actions are relevant to our current selection:
this.emitBranchNodeChangeIfNeeded();
this.focusActionForSelection();
}
} );
};
/**
* Update the suggestion count and the edit-full-page indicators
*
* @param {mw.editcheck.EditCheckAction[]} actions All current mid-edit actions
*/
Controller.prototype.updateSuggestionIndicators = function ( actions ) {
// System messages and any other future system checks shouldn't affect suggestion count
actions = actions.filter( ( action ) => !action.check.isSystemCheck() );
const suggestionCount = actions.filter( ( action ) => action.isSuggestion() ).length;
let availableSuggestionCount = suggestionCount;
const target = this.target;
if ( target.enableVisualSectionEditing && target.section !== null ) {
if ( !this.editFullPageIndicatorTop ) {
this.editFullPageIndicatorTop = new OO.ui.IconWidget( {
icon: 'lightbulb',
classes: [ 've-ui-editCheck-editFullPage-indicator' ]
} );
this.editFullPageIndicatorBottom = new OO.ui.IconWidget( {
icon: 'lightbulb',
classes: [ 've-ui-editCheck-editFullPage-indicator' ]
} );
target.switchToFullPageButtonTop.$label.append( this.editFullPageIndicatorTop.$element );
target.switchToFullPageButtonBottom.$label.append( this.editFullPageIndicatorBottom.$element );
}
const placement = this.getSuggestionPlacement( actions );
availableSuggestionCount = placement.inSection;
this.editFullPageIndicatorTop.toggle( placement.hasAbove );
this.editFullPageIndicatorBottom.toggle( placement.hasBelow );
}
// Ignore a count of 0 during initial setup
if ( !( this.inSetup && suggestionCount === 0 ) ) {
this.updateSuggestionCountDebounced( suggestionCount );
}
this.lastAvailableSuggestionCount = availableSuggestionCount;
};
/**
* Find where suggestions are, compared to the section that is being edited
*
* @param {mw.editcheck.EditCheckAction[]} actions
* @return {{inSection: number, hasAbove: boolean, hasBelow: boolean}} Number of
* suggestions in the section, and if there are suggestions above or below it
*/
Controller.prototype.getSuggestionPlacement = function ( actions ) {
const attachedRootRange = this.surface.getModel().getDocument().getAttachedRoot().getOuterRange();
const ranges = actions.filter( ( action ) => action.isSuggestion() )
.map( ( action ) => action.getFocusSelection().getCoveringRange() );
return {
inSection: ranges.filter( ( range ) => attachedRootRange.containsRange( range ) ).length,
hasAbove: ranges.some( ( range ) => range.end < attachedRootRange.start ),
hasBelow: ranges.some( ( range ) => range.start > attachedRootRange.end )
};
};
/**
* Open the mid-edit sidebar, if it is not open
*
* The sidebar reads the actions from the controller in its setup.
*
* @param {mw.editcheck.EditCheckAction[]} newActions Newly added actions, which the sidebar can focus
* @return {jQuery.Promise} Promise which resolves when the sidebar is open
*/
Controller.prototype.showSidebar = function ( newActions ) {
const windowName = OO.ui.isMobile() ? 'gutterSidebarEditCheckDialog' : 'sidebarEditCheckDialog';
if ( this.sidebarOpeningPromise ) {
// A second open before the first one finishes never resolves, so wait
// for the first one. Its setup can occur after the current window is
// set, and it reads the new actions then, so add these to them.
this.sidebarOpeningNewActions.push( ...newActions );
return this.sidebarOpeningPromise;
}
const currentWindow = this.surface.getSidebarDialogs().getCurrentWindow();
if ( currentWindow && currentWindow.constructor.static.name === windowName ) {
return ve.createDeferred().resolve().promise();
}
const target = this.target;
target.$element.addClass( 've-ui-editCheck-sidebar-active' );
const windowAction = ve.ui.actionFactory.create( 'window', this.surface, 'check' );
// Copy, because later calls add to this array
const openingNewActions = newActions.slice();
const openingPromise = windowAction.open(
windowName,
{ inBeforeSave: this.inBeforeSave, newActions: openingNewActions, controller: this }
).then( ( instance ) => {
ve.track( 'activity.editCheckDialog', { action: 'window-open-from-check-midedit' } );
instance.closed.then( () => {
target.$element.removeClass( 've-ui-editCheck-sidebar-active' );
} );
} );
this.sidebarOpeningPromise = openingPromise;
this.sidebarOpeningNewActions = openingNewActions;
openingPromise.always( () => {
if ( this.sidebarOpeningPromise === openingPromise ) {
this.sidebarOpeningPromise = null;
this.sidebarOpeningNewActions = null;
}
} );
return openingPromise;
};
/**
* Adds the pre-save edit check dialog before the normal page commit dialog.
* Handles closing the mid-edit dialog, as well as restoring it if the user
* exits the pre-save check dialog.
*
* We execute all pre-save checks, which may be asynchronous, and wait for them
* to complete before opening the pre-save dialog.
*
* TODO: Set a time-out so that we don't hang forever if an async check takes
* too long.
*/
Controller.prototype.setupPreSaveProcess = function () {
const target = this.target;
const preSaveProcess = target.getPreSaveProcess();
const trackPreSaveCheck = function ( kind ) {
ve.track( 'stats.mediawiki_editcheck_preSaveChecks_total', 1, {
wiki: mw.config.get( 'wgDBname' ),
kind
} );
};
preSaveProcess.next( () => {
const surface = target.getSurface();
if ( surface.getMode() !== 'visual' ) {
return;
}
trackPreSaveCheck( 'Available' );
const oldFocusedAction = this.focusedAction;
this.inBeforeSave = true;
return this.updateForListener( 'onBeforeSave' ).then( ( actions ) => {
if ( !this.surface ) {
// The user left the editing session during the time checks were being generated.
trackPreSaveCheck( 'Abandoned' );
return ve.createDeferred().reject().promise();
}
if ( actions.length ) {
trackPreSaveCheck( 'Shown' );
this.setupToolbar( target );
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;
}
return this.closeSidebars( 'preSaveProcess' ).then( () => this.closeDialog( 'preSaveProcess' ).then( () => {
target.onContainerScroll();
const windowAction = ve.ui.actionFactory.create( 'window', surface, 'check' );
return windowAction.open( 'fixedEditCheckDialog', { inBeforeSave: true, controller: this } )
.then( ( instance ) => {
ve.track( 'activity.editCheckDialog', { action: 'window-open-from-check-presave' } );
this.scrollActionIntoViewDebounced( this.focusedAction, { alignToTop: true } );
instance.closed.then( () => {}, () => {} ).then( () => {
surface.getView().setReviewMode( false );
this.inBeforeSave = false;
this.focusedAction = oldFocusedAction;
// Re-open the mid-edit sidebar if necessary.
this.refresh();
} );
return instance.closing.then( ( data ) => {
if ( target.deactivating || !target.active ) {
// Someone clicking "read" to leave the article
// will trigger the closing of this; in that
// case, just abandon what we're doing
trackPreSaveCheck( 'Abandoned' );
return ve.createDeferred().reject().promise();
}
this.restoreToolbar( target );
if ( $contextContainer ) {
surface.context.popup.$container = $contextContainer;
surface.context.popup.containerPadding = contextPadding;
}
target.onContainerScroll();
if ( data ) {
const delay = ve.createDeferred();
// If they inserted, wait 2 seconds on desktop
// before showing save dialog to give user time
// to see success notification.
setTimeout( () => {
trackPreSaveCheck( 'Completed' );
delay.resolve();
}, !OO.ui.isMobile() && data.action !== 'reject' ? 2000 : 0 );
return delay.promise();
} else {
// closed via "back" or otherwise
trackPreSaveCheck( 'Abandoned' );
return ve.createDeferred().reject().promise();
}
} );
} );
} ) );
} else {
this.inBeforeSave = false;
// Counterpart to earlier preSaveChecksShown, for use in tracking
// errors in check-generation:
trackPreSaveCheck( 'NotShown' );
}
} );
} );
};
/**
* Replace toolbar tools for review mode during pre-save checks.
*
* @param {ve.init.mw.ArticleTarget} target
*/
Controller.prototype.setupToolbar = function ( target ) {
const surface = target.getSurface();
const toolbar = target.getToolbar();
this.$originalToolbarTools = toolbar.$group.add( toolbar.$after ).addClass( 'oo-ui-element-hidden' );
const reviewToolbar = new ve.ui.TargetToolbar( target, target.toolbarConfig );
reviewToolbar.setup( [
{
name: 'back',
type: 'bar',
include: [ 'editCheckBack' ]
},
{
name: 'title',
type: 'label',
label: ve.msg( 'editcheck-dialog-title' )
},
{
name: 'save',
type: 'bar',
include: [ 'showSaveDisabled' ]
}
], surface );
reviewToolbar.items[ 1 ].$element.removeClass( 'oo-ui-toolGroup-empty' );
// Just append the $group of the new toolbar, so we don't have to wire up all the toolbar events.
this.$reviewToolbarGroup = reviewToolbar.$group.addClass( 've-ui-editCheck-toolbar-tools' );
toolbar.$group.after( this.$reviewToolbarGroup );
toolbar.onWindowResize();
};
/**
* Restores the original toolbar tools after review mode is complete.
*
* @param {ve.init.mw.ArticleTarget} target
*/
Controller.prototype.restoreToolbar = function ( target ) {
if ( !this.$reviewToolbarGroup ) {
return;
}
const toolbar = target.getToolbar();
this.$reviewToolbarGroup.remove();
this.$reviewToolbarGroup = null;
this.$originalToolbarTools.removeClass( 'oo-ui-element-hidden' );
toolbar.onWindowResize();
};
/**
* Redraw selection highlights
*/
Controller.prototype.drawSelections = function () {
const allActions = this.filterActionsForDisplay( this.getActions() );
const surfaceView = this.surface.getView();
const selectionManager = surfaceView.getSelectionManager();
const activeSelections = this.focusedAction ? this.focusedAction.getHighlightSelections().map(
( selection ) => ve.ce.Selection.static.newFromModel( selection, surfaceView )
) : [];
if ( this.focusedAction ) {
this.focusedAction.updateStale();
}
const isStale = !!this.focusedAction && this.focusedAction.isStale();
const showGutter = !isStale && !OO.ui.isMobile();
const activeOptions = {
showGutter,
showRects: !isStale,
showBounding: isStale,
wrapperClass: 've-ce-surface-selections-editCheck-active' +
( isStale ? ' ve-ce-surface-selections-editCheck-stale' : '' )
};
const inactiveOptions = {
showGutter,
showRects: true,
wrapperClass: 've-ce-surface-selections-editCheck-inactive'
};
if ( this.inBeforeSave ) {
// Review mode grays out everything that's not highlighted:
const highlightNodes = [];
allActions.forEach( ( action ) => {
action.getHighlightSelections().forEach( ( selection ) => {
highlightNodes.push( ...surfaceView.getDocument().selectNodes( selection.getCoveringRange(), 'branches' ).map( ( spec ) => spec.node ) );
} );
} );
surfaceView.setReviewMode( true, highlightNodes );
if ( this.focusedAction ) {
selectionManager.drawSelections( 'editCheck-active-' + this.focusedAction.getType(), activeSelections, activeOptions );
}
return;
}
[ 'warning', 'error', 'progressive', 'success' ].forEach( ( type ) => {
const actions = allActions.filter( ( action ) => action.getType() === type );
if ( actions.length === 0 ) {
// Clear any previously drawn selections
selectionManager.drawSelections( 'editCheck-active-' + type, [] );
selectionManager.drawSelections( 'editCheck-inactive-' + type, [] );
return;
}
const inactiveSelections = [];
actions.forEach( ( action ) => {
if ( action === this.focusedAction ) {
return;
}
action.getHighlightSelections().forEach( ( selection ) => {
const selectionView = ve.ce.Selection.static.newFromModel( selection, surfaceView );
inactiveSelections.push( selectionView );
} );
if ( action.widget ) {
action.widget.setFetchSelectionElements( () => {
const selectionElements = [];
action.getHighlightSelections().forEach( ( selection ) => {
const element = selectionManager.getCachedSelectionElements( 'editCheck-inactive-' + type, selection, inactiveOptions );
if ( element ) {
selectionElements.push( element );
}
} );
return selectionElements;
} );
}
} );
if ( isStale && activeSelections.length ) {
// When in reviewing a check (stale), suppress all inactive selections that overlap with the active selection (T420712).
const activeRange = activeSelections[ 0 ].getModel().getCoveringRange();
for ( let i = inactiveSelections.length - 1; i >= 0; i-- ) {
if ( activeRange.overlapsRange( inactiveSelections[ i ].getModel().getCoveringRange() ) ) {
inactiveSelections.splice( i, 1 );
}
}
}
// The following classes are used here:
// * ve-ce-surface-selections-editCheck-active-warning
// * ve-ce-surface-selections-editCheck-active-error
// * ve-ce-surface-selections-editCheck-active-progressive
// * ve-ce-surface-selections-editCheck-active-success
// * ve-ce-surface-selections-editCheck-inactive-warning
// * ve-ce-surface-selections-editCheck-inactive-error
// * ve-ce-surface-selections-editCheck-inactive-progressive
// * ve-ce-surface-selections-editCheck-inactive-success
selectionManager.drawSelections( 'editCheck-inactive-' + type, inactiveSelections, inactiveOptions );
const activeSelectionsForType = this.focusedAction && this.focusedAction.getType() === type ? activeSelections : [];
selectionManager.drawSelections( 'editCheck-active-' + type, activeSelectionsForType, activeOptions );
} );
};
/**
* Scrolls an action's selection into view
*
* @param {mw.editcheck.EditCheckAction} action
* @param {Object} [scrollConfig] Configuration for scrolling
* @return {jQuery.Promise} Promise resolved when scrolling is complete
*/
Controller.prototype.scrollActionIntoView = function ( action, scrollConfig ) {
// scrollSelectionIntoView scrolls to the focus of a selection, but we
// want the very beginning to be in view, so collapse it:
const selection = action.getHighlightSelections()[ 0 ].collapseToStart();
return this.surface.scrollSelectionIntoView( selection, ve.extendObject( {
animate: true,
extraPadding: { top: 10, bottom: OO.ui.isMobile() ? 200 : 10 }
}, scrollConfig ) );
};
/**
* Closes the edit check dialog
*
* @param {string} [action] Name of action which triggered the close ('mobile-keyboard', 'context', 'preSaveProcess')
* @return {jQuery.Promise}
*/
Controller.prototype.closeDialog = function ( action ) {
const currentWindow = this.surface.getToolbarDialogs( ve.ui.FixedEditCheckDialog.static.position ).getCurrentWindow();
if ( currentWindow && ( currentWindow.constructor.static.name === 'mobileEditCheckDialog' || currentWindow.constructor.static.name === 'fixedEditCheckDialog' ) ) {
// .always is not chainable
return currentWindow.close( action ? { action } : undefined ).closed.then( () => {}, () => {} );
}
return ve.createDeferred().resolve().promise();
};
/**
* Closes the sidebar edit check dialogs (mid-edit).
*
* @param {string} [action] Name of action which triggered the close (currently only 'preSaveProcess')
* @return {jQuery.Promise}
*/
Controller.prototype.closeSidebars = function ( action ) {
const currentWindow = this.surface.getSidebarDialogs().getCurrentWindow();
if ( currentWindow && currentWindow.constructor.static.name === 'sidebarEditCheckDialog' ) {
// .always is not chainable
return currentWindow.close( action ? { action } : undefined ).closed.then( () => {}, () => {} );
}
return ve.createDeferred().resolve().promise();
};
/**
* Set the current branch node from a selection
*
* @param {ve.dm.Selection} selection New selection
* @return {boolean} whether the branch node changed
*/
Controller.prototype.updateCurrentBranchNodeFromSelection = function ( selection ) {
let newBranchNode = null;
if ( selection instanceof ve.dm.LinearSelection ) {
newBranchNode = this.surface.model.documentModel.getBranchNodeFromOffset( selection.range.to );
}
if ( newBranchNode !== this.branchNode ) {
this.branchNode = newBranchNode;
return true;
}
return false;
};
/*
* Emit the branchNodeChange event if it's appropriate to do so
*
* That means: we're not in beforeSave and the selection has moved since
* this was last checked.
*
* @fires EditCheckController#branchNodeChange
*/
Controller.prototype.emitBranchNodeChangeIfNeeded = function () {
const selection = this.surface.getModel().getSelection();
if ( !this.inBeforeSave && this.updateCurrentBranchNodeFromSelection( selection ) ) {
this.emit( 'branchNodeChange', this.branchNode );
}
};
/**
* Count one lifecycle event against the community-defined rule that caused it
*
* The rule ID has no fixed set of values, so a cross with the check labels
* makes too many series.
*
* @param {mw.editcheck.EditCheckAction} action
* @param {string} event Lifecycle event: 'shown', 'seen', or the choice taken
*/
Controller.prototype.trackRule = function ( action, event ) {
const ruleId = action.getRuleId();
if ( ruleId === null ) {
return;
}
ve.track( 'stats.mediawiki_editcheck_rules_total', 1, {
wiki: mw.config.get( 'wgDBname' ),
kind: action.check.getName(),
rule: mw.editcheck.sanitizeStatsvLabel( ruleId ),
event
} );
};
/**
* Handle instrumentation and tracking when an action is shown or marked as seen
*
* @param {mw.editcheck.EditCheckAction} action Action that was seen or shown
* @param {string} seenOrShown Whether the action was 'seen' or 'shown'
*/
Controller.prototype.onActionSeenOrShown = function ( action, seenOrShown ) {
const moment = this.inBeforeSave ? 'presave' : 'midedit';
const name = action.getName();
const type = action.isSuggestion() ? 'suggestion' : 'check';
const data = { action: `${ type }-${ seenOrShown }-${ moment }${ action.getTrackingIdSuffix() }` };
if ( action.isSuggestion() ) {
mw.editcheck.state.suggestions[ seenOrShown ][ name ] = true;
} else {
mw.editcheck.state.checks[ seenOrShown ][ name ] = true;
}
ve.track( `activity.editCheck-${ name }`, data );
// The EventLogging event above is sampled and only readable in Superset,
// so keep an unsampled counter for Grafana.
ve.track( 'stats.mediawiki_editcheck_actions_total', 1, {
wiki: mw.config.get( 'wgDBname' ),
kind: action.check.getName(),
type,
event: seenOrShown,
moment
} );
this.trackRule( action, seenOrShown );
};
/**
* Handle instrumentation and tracking when an action is used
*
* @param {mw.editcheck.EditCheckAction} action that was used
* @param {Promise|jQuery.Promise} promise that will resolve when the action finishes
* @param {string} actionTaken name of the action taken
*/
Controller.prototype.onActionAct = function ( action, promise, actionTaken ) {
const name = action.getName();
const type = action.isSuggestion() ? 'suggestion' : 'check';
const data = {
action: ( action.isSuggestion() ? 'suggestion-' : '' ) + 'action-' + ( actionTaken || 'unknown' ) + action.getTrackingIdSuffix()
};
ve.track( `activity.editCheck-${ name }`, data );
// Each check declares its choices in static.choices, so `act` stays bounded.
ve.track( 'stats.mediawiki_editcheck_actionsTaken_total', 1, {
wiki: mw.config.get( 'wgDBname' ),
kind: action.check.getName(),
type,
act: actionTaken || 'unknown',
moment: this.inBeforeSave ? 'presave' : 'midedit'
} );
this.trackRule( action, actionTaken || 'unknown' );
if ( action.check.constructor.static.dismissalActions.includes( actionTaken ) ) {
// These are actions that represent "don't change anything", and so
// don't count as the check having been used
return;
}
if ( action.isSuggestion() ) {
mw.editcheck.state.suggestions.used[ name ] = true;
} else {
mw.editcheck.state.checks.used[ name ] = true;
}
};
Controller.prototype.onActionCompleted = function ( action, message ) {
if ( this.inBeforeSave ) {
// SystemMessageEditCheck has no onBeforeSave listener so a message
// registered here wouldn't show until we're back mid-edit, at which point
// it could suddenly appear alongside unrelated ones - which we don't want
return;
}
const config = {
title: message || action.check.constructor.static.successResult || OO.ui.deferMsg(
action.suggestion ? 'editcheck-dialog-success-suggestion' : 'editcheck-dialog-success-check'
),
suggestion: action.suggestion
};
if ( action.isSuggestion() && this.consumePublishPath() ) {
config.messageType = 'publishPath';
config.dismissOnOtherFocus = true;
const hasOtherSuggestions = this.getActions().some(
( candidate ) => candidate !== action && candidate.isSuggestion() && !candidate.check.isSystemCheck()
);
if ( !hasOtherSuggestions ) {
config.choices = mw.editcheck.SystemMessageEditCheck.static.messageTypes.publishPath.choices
.filter( ( choice ) => choice.action !== 'viewMoreSuggestions' );
}
}
this.registerSystemMessage( action.fragments[ action.fragments.length - 1 ], config );
};
/**
* Check if the publish path card can show, and if so, do not show it again
*
* @return {boolean} The publish path card can show
*/
Controller.prototype.consumePublishPath = function () {
if ( !mw.editcheck.showPublishPath ) {
return false;
}
mw.editcheck.showPublishPath = false;
if ( !mw.user.isAnon() ) {
new mw.Api().saveOption( 'visualeditor-editcheck-showpublishpath', '0' );
mw.user.options.set( 'visualeditor-editcheck-showpublishpath', '0' );
}
return true;
};
module.exports = {
Controller
};