/*!
* VisualEditor UserInterface DesktopContext class.
*
* @copyright See AUTHORS.txt
*/
/**
* Context menu and inspectors.
*
* @class
* @extends ve.ui.LinearContext
*
* @constructor
* @param {ve.ui.Surface} surface
* @param {Object} [config] Configuration options
* @param {jQuery} [config.$popupContainer] Clipping container for context popup
* @param {number} [config.popupPadding=10] Padding between popup and $popupContainer, can be negative
*/
ve.ui.DesktopContext = function VeUiDesktopContext( surface, config ) {
config = config || {};
// Parent constructor
ve.ui.DesktopContext.super.apply( this, arguments );
// Properties
this.popup = new OO.ui.PopupWidget( {
hideWhenOutOfView: false,
autoFlip: false,
$container: config.$popupContainer || this.surface.$element,
containerPadding: config.popupPadding
} );
this.position = null;
this.embeddable = null;
this.boundingRect = null;
this.transitioning = null;
this.dimensions = null;
this.suppressed = false;
this.onWindowScrollDebounced = ve.debounce( this.onWindowScroll.bind( this ) );
this.onWindowResizeHandler = this.onPosition.bind( this );
this.$window = $( this.getElementWindow() );
// Events
this.surface.getView().connect( this, {
relocationStart: 'onSuppress',
relocationEnd: 'onUnsuppress',
position: 'onPosition'
} );
this.inspectors.connect( this, {
resize: 'onInspectorResize'
} );
this.$window.on( {
resize: this.onWindowResizeHandler
} );
this.surface.$scrollListener[ 0 ].addEventListener( 'scroll', this.onWindowScrollDebounced, { passive: true } );
// Initialization
this.$element
.addClass( 've-ui-desktopContext' )
.append( this.$focusTrapBefore, this.popup.$element, this.$focusTrapAfter );
this.$group.addClass( 've-ui-desktopContext-menu' );
this.popup.$body.append( this.$group, this.inspectors.$element );
};
/* Inheritance */
OO.inheritClass( ve.ui.DesktopContext, ve.ui.LinearContext );
/* Methods */
/**
* @inheritdoc
*/
ve.ui.DesktopContext.prototype.afterContextChange = function () {
// Parent method
ve.ui.DesktopContext.super.prototype.afterContextChange.call( this );
// Bypass while dragging
if ( this.suppressed ) {
return;
}
};
/**
* Handle context suppression event.
*/
ve.ui.DesktopContext.prototype.onSuppress = function () {
this.suppressed = true;
if ( this.isVisible() ) {
if ( !this.isEmpty() ) {
// Change state: menu -> closed
this.toggleMenu( false );
this.toggle( false );
} else if ( this.inspector ) {
// Change state: inspector -> closed
this.inspector.close();
}
}
};
/**
* Handle context unsuppression event.
*/
ve.ui.DesktopContext.prototype.onUnsuppress = function () {
this.suppressed = false;
if ( this.isInspectable() ) {
// Change state: closed -> menu
this.toggleMenu( true );
this.toggle( true );
}
};
/**
* Handle cursor position change event.
*/
ve.ui.DesktopContext.prototype.onPosition = function () {
if ( this.isVisible() ) {
this.updateDimensionsDebounced();
}
};
/**
* @inheritdoc
*/
ve.ui.DesktopContext.prototype.createInspectorWindowManager = function () {
return new ve.ui.DesktopInspectorWindowManager( this.surface, {
factory: ve.ui.windowFactory,
overlay: this.surface.getLocalOverlay(),
modal: false
} );
};
/**
* @inheritdoc
*/
ve.ui.DesktopContext.prototype.onInspectorOpening = function () {
ve.ui.DesktopContext.super.prototype.onInspectorOpening.apply( this, arguments );
// Resize the popup before opening so the body height of the window is measured correctly
this.setPopupSizeAndPosition();
};
/**
* Handle inspector resize events
*/
ve.ui.DesktopContext.prototype.onInspectorResize = function () {
this.updateDimensionsDebounced();
};
/**
* @inheritdoc
*/
ve.ui.DesktopContext.prototype.toggle = function ( show ) {
if ( this.transitioning ) {
return this.transitioning;
}
show = show === undefined ? !this.visible : !!show;
if ( show === this.visible ) {
return ve.createDeferred().resolve().promise();
}
this.transitioning = ve.createDeferred();
const promise = this.transitioning.promise();
// Parent method
ve.ui.DesktopContext.super.prototype.toggle.call( this, show );
this.popup.toggle( show );
this.transitioning.resolve();
this.transitioning = null;
this.visible = show;
if ( show ) {
if ( this.inspector ) {
this.inspector.updateSize();
}
// updateDimensionsDebounced is not necessary here and causes a movement flicker
this.updateDimensions();
} else if ( this.inspector ) {
this.inspector.close();
}
return promise;
};
/**
* @inheritdoc
*/
ve.ui.DesktopContext.prototype.updateDimensions = function () {
const $container = this.inspector ? this.inspector.$frame : this.$group;
// Parent method
ve.ui.DesktopContext.super.prototype.updateDimensions.call( this );
if ( !this.isVisible() ) {
return;
}
const rtl = this.surface.getModel().getDocument().getDir() === 'rtl';
const surface = this.surface.getView();
const focusedNode = surface.getFocusedNode();
// Selection when the inspector was opened. Used to stop the context from
// jumping when an inline selection expands, e.g. to cover a long word
let startingSelection;
if (
!focusedNode && this.inspector && this.inspector.initialFragment &&
// Don't use initial selection if it comes from another document,
// e.g. the fake document used in source mode.
this.inspector.getFragment() &&
this.inspector.getFragment().getDocument() === surface.getModel().getDocument()
) {
startingSelection = this.inspector.initialFragment.getSelection();
}
const currentSelection = this.surface.getModel().getSelection();
const isTableSelection = ( startingSelection || currentSelection ) instanceof ve.dm.TableSelection;
const boundingRect = isTableSelection ?
surface.getSelection( startingSelection ).getTableBoundingRect() :
surface.getSelection( startingSelection ).getSelectionBoundingRect();
this.$element.removeClass( 've-ui-desktopContext-embedded' );
let position;
let middle;
let embeddable = false;
if ( !boundingRect ) {
// If !boundingRect, the surface apparently isn't selected.
// This shouldn't happen because the context is only supposed to be
// displayed in response to a selection, but it sometimes does happen due
// to browser weirdness.
// Skip updating the cursor position, but still update the width and height.
this.popup.toggleAnchor( true );
this.popup.setAlignment( 'center' );
} else if ( isTableSelection || ( focusedNode && !focusedNode.isContent() ) ) {
embeddable = this.isEmbeddable() &&
boundingRect.height > this.$group.outerHeight() + 5 &&
boundingRect.width > this.$group.outerWidth() + 10;
this.popup.toggleAnchor( !embeddable );
this.$element.toggleClass( 've-ui-desktopContext-embedded', !!embeddable );
if ( embeddable ) {
// Embedded context position depends on directionality
position = {
x: rtl ? boundingRect.left : boundingRect.right,
y: boundingRect.top
};
this.popup.setAlignment( 'backwards' );
} else {
// Position the context underneath the center of the node
middle = ( boundingRect.left + boundingRect.right ) / 2;
position = {
x: middle,
y: boundingRect.bottom
};
this.popup.setAlignment( 'center' );
}
} else {
// The selection is text or an inline focused node
const startAndEndRects = surface.getSelection( startingSelection ).getSelectionStartAndEndRects();
if ( startAndEndRects ) {
middle = ( boundingRect.left + boundingRect.right ) / 2;
if (
( !rtl && startAndEndRects.end.right > middle ) ||
( rtl && startAndEndRects.end.left < middle )
) {
// If the middle position is within the end rect, use it
position = {
x: middle,
y: boundingRect.bottom
};
} else {
// ..otherwise use the side of the end rect
position = {
x: rtl ? startAndEndRects.end.left : startAndEndRects.end.right,
y: startAndEndRects.end.bottom
};
}
}
this.popup.toggleAnchor( true );
this.popup.setAlignment( 'center' );
}
if ( position ) {
this.position = position;
}
if ( boundingRect ) {
this.boundingRect = boundingRect;
}
this.embeddable = embeddable;
this.dimensions = {
width: $container.outerWidth( true ),
height: $container.outerHeight( true )
};
this.setPopupSizeAndPosition();
return this;
};
/**
* Handle window scroll events
*
* @param {jQuery.Event} e Scroll event
*/
ve.ui.DesktopContext.prototype.onWindowScroll = function () {
this.setPopupSizeAndPosition( true );
};
/**
* Check if the context menu for current content is embeddable.
*
* @return {boolean} Context menu is embeddable
*/
ve.ui.DesktopContext.prototype.isEmbeddable = function () {
const sources = this.getRelatedSources();
for ( let i = 0, len = sources.length; i < len; i++ ) {
if ( !sources[ i ].embeddable ) {
return false;
}
}
return true;
};
/**
* Apply the popup's size and position, within the bounds of the viewport
*
* @param {boolean} [repositionOnly] Reposition the popup only
*/
ve.ui.DesktopContext.prototype.setPopupSizeAndPosition = function ( repositionOnly ) {
if ( !this.isVisible() ) {
return;
}
const surface = this.surface;
const viewport = surface.getViewportDimensions();
if ( !viewport || !this.dimensions ) {
// viewport can be null if the surface is not attached
return;
}
const margin = 10,
minimumVisibleHeight = 100;
if ( this.popup.hasAnchor() ) {
// Reserve space for the anchor and one line of text
// ('40' is arbitrary and has been picked by experimentation)
viewport.top += 40;
viewport.height -= 40;
}
if ( this.position ) {
// Float the content if it's bigger than the viewport. Exactly how /
// whether it should be floated is situational, so this is a
// preliminary determination. Checks below might cancel the float.
let floating =
( !this.embeddable && this.position.y + this.dimensions.height > viewport.bottom - margin ) ||
( this.embeddable && this.position.y < viewport.top + margin );
if ( floating ) {
if ( this.embeddable ) {
if ( this.boundingRect.bottom - viewport.top - minimumVisibleHeight < this.dimensions.height + margin ) {
floating = false;
this.$element.css( {
left: this.position.x,
top: this.position.y + this.boundingRect.height - this.dimensions.height - minimumVisibleHeight,
bottom: ''
} );
} else {
this.$element.css( {
left: this.position.x + viewport.left,
top: this.surface.getPadding().top + margin,
bottom: ''
} );
}
} else {
if ( viewport.bottom - this.boundingRect.top - minimumVisibleHeight < this.dimensions.height + margin ) {
floating = false;
this.$element.css( {
left: this.position.x,
top: this.position.y,
bottom: ''
} );
} else {
this.$element.css( {
left: this.position.x + viewport.left,
top: '',
bottom: this.dimensions.height + margin
} );
}
}
} else {
this.$element.css( {
left: this.position.x,
top: this.position.y,
bottom: ''
} );
}
this.$element.toggleClass( 've-ui-desktopContext-floating', !!floating );
this.popup.toggleAnchor( !floating && !this.embeddable );
}
if ( !repositionOnly ) {
// PopupWidget normally is clippable, suppress that to be able to resize and scroll it into view.
// Needs to be repeated before every call, as it resets itself when the popup is shown or hidden.
this.popup.toggleClipping( false );
// We want to stop the popup from possibly being bigger than the viewport (T114614),
// as that can result in situations where it's impossible to reach parts
// of the popup. Limiting it to the window height would ignore toolbars
// and the find-replace dialog and suchlike. We can't use getViewportDimensions
// as that doesn't account for the surface height "growing" when we scroll (T304847).
const maxSurfaceHeight = this.surface.$scrollContainer.height() -
this.surface.getPadding().top -
// Allow room for callout and cursor above the context
30;
this.popup.setSize( this.dimensions.width, Math.min( this.dimensions.height, maxSurfaceHeight ) );
this.popup.scrollElementIntoView( { animate: false } );
}
};
/**
* @inheritdoc
*/
ve.ui.DesktopContext.prototype.destroy = function () {
// Hide, so a debounced updateDimensions does nothing
this.toggle( false );
// Disconnect
this.surface.getView().disconnect( this );
this.surface.getModel().disconnect( this );
this.inspectors.disconnect( this );
this.$window.off( {
resize: this.onWindowResizeHandler
} );
this.surface.$scrollListener[ 0 ].removeEventListener( 'scroll', this.onWindowScrollDebounced );
// Popups bind scroll events if they're in positioning mode, so make sure that's disabled
this.popup.togglePositioning( false );
// Parent method
return ve.ui.DesktopContext.super.prototype.destroy.call( this );
};