/*!
* VisualEditor ContentEditable FocusableNode class.
*
* @copyright See AUTHORS.txt
*/
/**
* ContentEditable focusable node.
*
* Focusable elements have a special treatment by ve.ce.Surface. When the user selects only a single
* node, if it is focusable, the surface will set the focusable node's focused state. Other systems,
* such as the context, may also use a focusable node's $focusable property as a hint of where the
* primary element in the node is. Typically, and by default, the primary element is the root
* element, but in some cases it may need to be configured to be a specific child element within the
* node's DOM rendering.
*
* If your focusable node changes size and the highlight must be redrawn, call redrawHighlights().
* 'resizeEnd' and 'rerender' are already bound to call this.
*
* @class
* @abstract
*
* @constructor
* @param {jQuery} [$focusable=this.$element] Primary element user is focusing on
* @param {Object} [config] Configuration options
* @param {jQuery} [config.$bounding=$focusable] Element to consider for bounding box calculations (e.g.
* attaching inspectors)
* @param {string[]} [config.classes] CSS classes to be added to the highlight container
*/
ve.ce.FocusableNode = function VeCeFocusableNode( $focusable, config ) {
config = config || {};
// Properties
this.focused = false;
this.highlighted = false;
this.isFocusableSetup = false;
this.$highlights = $( '<div>' ).addClass( 've-ce-focusableNode-highlights' )
// Allow the highlight to take focus, so that focus is not removed from
// the surface when clicking on it (T341681)
.attr( 'tabIndex', 0 );
this.$focusable = $focusable || this.$element;
this.$bounding = config.$bounding || this.$focusable;
this.focusableSurface = null;
this.rects = null;
this.boundingRect = null;
this.startAndEndRects = null;
this.icon = null;
this.touchMoved = false;
if ( Array.isArray( config.classes ) ) {
// eslint-disable-next-line mediawiki/class-doc
this.$highlights.addClass( config.classes );
}
// Use a debounced handler as some actions can trigger redrawHighlights
// twice in quick succession resizeEnd+rerender
this.redrawHighlightsDebounced = ve.debounce( this.redrawHighlights.bind( this ), 100 );
// DOM changes
this.$element
.addClass( 've-ce-focusableNode' )
.prop( 'contentEditable', 'false' );
// Events
this.connect( this, {
setup: 'onFocusableSetup',
teardown: 'onFocusableTeardown',
resizeStart: 'onFocusableResizeStart',
resizeEnd: 'onFocusableResizeEnd',
rerender: 'onFocusableRerender'
} );
};
/* Inheritance */
OO.initClass( ve.ce.FocusableNode );
/* Events */
/**
* @event ve.ce.FocusableNode#focus
*/
/**
* @event ve.ce.FocusableNode#blur
*/
/* Static properties */
/**
* Icon to use when the rendering is considered not visible, as defined in #hasRendering
*
* No icon is show if null.
*
* @static
* @property {string|null}
* @inheritable
*/
ve.ce.FocusableNode.static.iconWhenInvisible = null;
/**
* Command to execute when Enter is pressed while this node is focused, or when the node is double-clicked.
*
* @static
* @property {string|null}
* @inheritable
*/
ve.ce.FocusableNode.static.primaryCommandName = null;
/**
* Command to execute when Delete or Backspace is pressed while this node is focused.
*
* @static
* @property {string|null}
* @inheritable
*/
ve.ce.FocusableNode.static.deleteCommandName = null;
/* Static methods */
/**
* Get rects for an element
*
* @param {jQuery} $element Element to get highlights
* @param {Object} [relativeRect] Rect with top & left to get position relative to
* @return {Object} Object containing rects and boundingRect
*/
ve.ce.FocusableNode.static.getRectsForElement = function ( $element, relativeRect ) {
let $set;
let rects = [];
function process( el ) {
if ( el.classList.contains( 've-ce-noHighlight' ) ) {
return;
}
const $el = $( el );
// Don't descend if overflow is anything but visible as this prevents child
// elements appearing beyond the bounding box of the parent, *unless* display
// is inline, in which case the overflow setting will be ignored
const overflow = $el.css( 'overflow' );
if ( overflow && overflow !== 'visible' && $el.css( 'display' ) !== 'inline' ) {
$set = $set.not( $el.find( '*' ) );
}
ve.batchPush( rects, el.getClientRects() );
}
$set = $element.find( '*' ).addBack();
// Calling process() may change $set.length
for ( let i = 0; i < $set.length; i++ ) {
process( $set[ i ] );
}
rects = ve.minimizeRects( rects );
// Elements with a width/height of 0 return a clientRect with a width/height of 1
// As elements with an actual width/height of 1 aren't that useful anyway, just
// throw away anything that is <=1
const filteredRects = rects.filter( ( rect ) => rect.width > 1 && rect.height > 1 );
// But if this filtering doesn't leave any rects at all, then we do want to use the 1px rects
if ( filteredRects.length > 0 ) {
rects = filteredRects;
}
let boundingRect = null;
for ( let i = 0, l = rects.length; i < l; i++ ) {
// Translate to relative
if ( relativeRect ) {
rects[ i ] = ve.translateRect( rects[ i ], -relativeRect.left, -relativeRect.top );
}
if ( !boundingRect ) {
boundingRect = ve.copy( rects[ i ] );
} else {
boundingRect.top = Math.min( boundingRect.top, rects[ i ].top );
boundingRect.left = Math.min( boundingRect.left, rects[ i ].left );
boundingRect.bottom = Math.max( boundingRect.bottom, rects[ i ].bottom );
boundingRect.right = Math.max( boundingRect.right, rects[ i ].right );
}
}
if ( boundingRect ) {
boundingRect.width = boundingRect.right - boundingRect.left;
boundingRect.height = boundingRect.bottom - boundingRect.top;
}
return {
rects: rects,
boundingRect: boundingRect
};
};
/* Methods */
/**
* Create a highlight element.
*
* @return {jQuery} A highlight element
*/
ve.ce.FocusableNode.prototype.createHighlight = function () {
// eslint-disable-next-line mediawiki/class-doc
return $( '<div>' )
.addClass( [ 've-ce-focusableNode-highlight', ...this.getExtraHighlightClasses() ] )
.prop( {
title: this.constructor.static.getDescription( this.model ),
draggable: true
} )
.on( {
dragstart: this.onFocusableDragStart.bind( this ),
dragend: this.onFocusableDragEnd.bind( this )
} );
};
/**
* Array of CSS classes to add to highlights
*
* @return {string[]}
*/
ve.ce.FocusableNode.prototype.getExtraHighlightClasses = function () {
return this.generatedContentsInvalid ? [ 've-ce-focusableNode-highlight-error' ] : [];
};
/**
* Handle node setup.
*/
ve.ce.FocusableNode.prototype.onFocusableSetup = function () {
// Exit if already setup or not attached
if ( this.isFocusableSetup || !this.root ) {
return;
}
this.focusableSurface = this.root.getSurface();
// DOM changes (duplicated from constructor in case this.$element is replaced)
// eslint-disable-next-line no-jquery/no-class-state
if ( !this.$element.hasClass( 've-ce-focusableNode' ) ) {
// Optimization: If this.$element has the correct class assume it has already
// been setup in the construct and avoid expensive DOM property manipulation
this.$element
.addClass( 've-ce-focusableNode' )
.prop( 'contentEditable', 'false' );
}
// Events
this.$focusable.on( {
'mouseenter.ve-ce-focusableNode': this.onFocusableMouseEnter.bind( this ),
'touchstart.ve-ce-focusableNode': this.onFocusableTouchStart.bind( this ),
'touchmove.ve-ce-focusableNode': this.onFocusableTouchMove.bind( this ),
'mousedown.ve-ce-focusableNode touchend.ve-ce-focusableNode': this.onFocusableMouseDown.bind( this )
} );
// $element is ce=false so make sure nothing happens when you click
// on it, just in case the browser decides to do something.
// If $element == $focusable then this can be skipped as $focusable already
// handles mousedown events.
if ( !this.$element.is( this.$focusable ) ) {
this.$element.on( {
'mousedown.ve-ce-focusableNode': function ( e ) {
if ( !ve.isContentEditable( e.target ) ) {
e.preventDefault();
}
}
} );
}
// Note that preventing default on mousedown doesn't suppress click
// events, so link navigation would still occur:
this.$element.on( {
'click.ve-ce-focusableNode': function ( e ) {
if ( !ve.isContentEditable( e.target ) && e.which === OO.ui.MouseButtons.LEFT ) {
e.preventDefault();
}
}
} );
if ( this.constructor.static.iconWhenInvisible ) {
// Set up the invisible icon, and watch for its continued necessity if
// unloaded images which don't specify their width or height are
// involved.
this.$element
.find( 'img:not([width]),img:not([height])' )
.addBack( 'img:not([width]),img:not([height])' )
.on( 'load', this.updateInvisibleIcon.bind( this ) );
this.updateInvisibleIcon();
}
// A node may be re-setup when focused. redrawHighlights will only do
// something if the node is currently focused.
this.redrawHighlightsDebounced();
this.isFocusableSetup = true;
};
/**
* Update the state of icon if this node is invisible
*
* If the node doesn't have a visible rendering, we insert an icon to represent
* it. If the icon was already present, and this is called again when rendering
* has developed, we remove the icon.
*/
ve.ce.FocusableNode.prototype.updateInvisibleIcon = function () {
if ( !this.constructor.static.iconWhenInvisible ) {
return;
}
// Make sure any existing icon is detached before measuring
if ( this.icon ) {
this.icon.$element.detach();
}
const showIcon = !this.hasRendering();
// Defer updating the DOM. If we don't do this, the hasRendering() call for the next
// FocusableNode will force a reflow, which is slow.
requestAnimationFrame( () => {
this.updateInvisibleIconSync( showIcon );
} );
};
/**
* Synchronous part of #updateInvisibleIconSync
*
* @param {boolean} showIcon Show the icon
* @private
*/
ve.ce.FocusableNode.prototype.updateInvisibleIconSync = function ( showIcon ) {
if ( !this.getModel() ) {
// Check the node hasn't been destroyed, as this method is called after a requestAnimationFrame
return;
}
if ( showIcon ) {
// Don't try to append to void tags, or unrendered tags
const voidAndHiddenTypes = [ 'style', 'script', ...ve.elementTypes.void ];
const $firstElement = this.$element.not( voidAndHiddenTypes.join( ',' ) ).first();
this.createInvisibleIcon();
if (
// Not needed if node is not attached (e.g. if used in the converter)
document.body.contains( $firstElement[ 0 ] ) &&
// eslint-disable-next-line no-jquery/no-sizzle
!$firstElement.is( ':visible' )
) {
// The first element to which we want to attach our icon is invisible.
// In this case make sure it *is* visible, so the button is visible,
// but remove the contents, so we don't start showing them (T305110).
$firstElement.empty().css( 'display', 'inline-block' );
}
$firstElement
.addClass( 've-ce-focusableNode-invisible' )
.prepend( this.icon.$element );
} else if ( this.icon ) {
this.$element.removeClass( 've-ce-focusableNode-invisible' );
this.icon.$element.detach();
}
};
/**
* Create a element to show if the node is invisible
*/
ve.ce.FocusableNode.prototype.createInvisibleIcon = function () {
if ( this.icon ) {
return;
}
this.icon = new OO.ui.ButtonWidget( {
classes: [ 've-ce-focusableNode-invisibleIcon' ],
framed: false,
// Make button unfocusable, T198912
tabIndex: null,
icon: this.constructor.static.iconWhenInvisible
} );
this.updateInvisibleIconLabel();
};
/**
* Get a label for the invisible icon
*
* Defaults to #getDescription
*
* @return {jQuery|string|OO.ui.HtmlSnippet|Function|null} Invisible icon label
*/
ve.ce.FocusableNode.prototype.getInvisibleIconLabel = function () {
return this.model ? this.constructor.static.getDescription( this.model ) : '';
};
/**
* Update the invisible icon's label
*/
ve.ce.FocusableNode.prototype.updateInvisibleIconLabel = function () {
if ( this.icon ) {
this.icon.setLabel( this.getInvisibleIconLabel() || null );
}
};
/**
* Handle node teardown.
*/
ve.ce.FocusableNode.prototype.onFocusableTeardown = function () {
// Exit if not setup or not attached
if ( !this.isFocusableSetup || !this.root ) {
return;
}
// Events
this.$focusable.off( '.ve-ce-focusableNode' );
this.$element.off( '.ve-ce-focusableNode' );
// Highlights
this.clearHighlights();
// DOM changes
this.$element
.removeClass( 've-ce-focusableNode' )
.removeProp( 'contentEditable' );
this.focusableSurface = null;
this.isFocusableSetup = false;
};
/**
* Handle highlight mouse down events.
*
* @param {jQuery.Event} e Mouse down event
*/
ve.ce.FocusableNode.prototype.onFocusableMouseDown = function ( e ) {
if ( e.type === 'touchend' && this.touchMoved ) {
return;
}
if ( this.isInContentEditableDisabled() ) {
return;
}
const surfaceModel = this.focusableSurface.getModel(),
selection = surfaceModel.getSelection(),
nodeRange = this.model.getOuterRange();
if ( e.which === OO.ui.MouseButtons.RIGHT ) {
// The same technique is used in ve.ce.TableNode:
// Make ce=true so we get cut/paste options in the context menu
this.$highlights.prop( 'contentEditable', 'true' );
// Select the clicked element so we get a copy option in the context menu
ve.selectElement( this.$highlights[ 0 ] );
setTimeout( () => {
// Undo ce=true as soon as the context menu is shown
this.$highlights.prop( 'contentEditable', 'false' );
this.focusableSurface.prepareClipboardHandlerForCopy();
} );
}
// Wait for native selection to change before correcting
setTimeout( () => {
// Check surface still exists after timeout
if ( this.focusableSurface ) {
const range = selection instanceof ve.dm.LinearSelection && selection.getRange();
surfaceModel.getLinearFragment(
e.shiftKey && range ?
ve.Range.static.newCoveringRange(
[ range, nodeRange ], range.from > nodeRange.from
) :
nodeRange
).select();
this.focusableSurface.updateActiveAnnotations();
// Ensure surface is active as native 'focus' event won't be fired
this.focusableSurface.activate();
}
} );
};
/**
* Handle highlight double click events.
*
* @param {jQuery.Event} e Double click event
*/
ve.ce.FocusableNode.prototype.onFocusableDblClick = function () {
if ( this.isInContentEditableDisabled() ) {
return;
}
if ( this.getModel().isEditable() ) {
this.executeCommand();
}
};
/**
* Execute the command associated with this node.
*/
ve.ce.FocusableNode.prototype.executeCommand = function () {
if ( !this.model.isInspectable() ) {
return;
}
const surface = this.focusableSurface.getSurface();
const command = surface.commandRegistry.getCommandForNode( this );
if ( command ) {
command.execute( surface );
}
};
/**
* Handle element drag start.
*
* @param {jQuery.Event} e Drag start event
*/
ve.ce.FocusableNode.prototype.onFocusableDragStart = function ( e ) {
if ( this.focusableSurface ) {
// Pass event up to the surface
this.focusableSurface.$element.trigger( e );
}
};
/**
* Handle element drag end.
*
* If a relocation actually takes place the node is destroyed before this events fires.
*
* @param {jQuery.Event} e Drag end event
*/
ve.ce.FocusableNode.prototype.onFocusableDragEnd = function ( e ) {
// endRelocation is usually triggered by onDocumentDrop in the surface, but if it isn't
// trigger it here instead
if ( this.focusableSurface ) {
this.focusableSurface.$element.trigger( e );
}
};
/**
* Handle mouse enter events.
*
* @param {jQuery.Event} e Mouse enter event
*/
ve.ce.FocusableNode.prototype.onFocusableMouseEnter = function () {
if ( !this.root.getSurface().dragging && !this.root.getSurface().resizing && !this.isInContentEditableDisabled() ) {
this.createHighlights();
}
};
/**
* Handle touch start events.
*
* @param {jQuery.Event} e Touch start event
*/
ve.ce.FocusableNode.prototype.onFocusableTouchStart = function () {
this.touchMoved = false;
};
/**
* Handle touch move events.
*
* @param {jQuery.Event} e Touch move event
*/
ve.ce.FocusableNode.prototype.onFocusableTouchMove = function () {
this.touchMoved = true;
};
/**
* Handle surface mouse move events.
*
* @param {jQuery.Event} e Mouse move event
*/
ve.ce.FocusableNode.prototype.onSurfaceMouseMove = function ( e ) {
const $target = $( e.target );
if (
// eslint-disable-next-line no-jquery/no-class-state
!$target.hasClass( 've-ce-focusableNode-highlight' ) &&
!OO.ui.contains( this.$focusable.toArray(), $target[ 0 ], true )
) {
this.clearHighlights();
}
};
/**
* Handle surface mouse leave events.
*
* @param {jQuery.Event} e Mouse leave event
*/
ve.ce.FocusableNode.prototype.onSurfaceMouseLeave = function ( e ) {
if ( e.relatedTarget === null ) {
this.clearHighlights();
}
};
/**
* Handle resize start events.
*/
ve.ce.FocusableNode.prototype.onFocusableResizeStart = function () {
this.clearHighlights();
};
/**
* Handle resize end event.
*/
ve.ce.FocusableNode.prototype.onFocusableResizeEnd = function () {
this.redrawHighlightsDebounced();
};
/**
* Handle rerender event.
*/
ve.ce.FocusableNode.prototype.onFocusableRerender = function () {
if ( this.focused && this.focusableSurface ) {
this.redrawHighlightsDebounced();
// Reposition menu
this.focusableSurface.getSurface().getContext().updateDimensions( true );
}
};
/**
* Check if node is focused.
*
* @return {boolean} Node is focused
*/
ve.ce.FocusableNode.prototype.isFocused = function () {
return this.focused;
};
/**
* Set the selected state of the node.
*
* @param {boolean} value Node is focused
* @fires ve.ce.FocusableNode#focus
* @fires ve.ce.FocusableNode#blur
*/
ve.ce.FocusableNode.prototype.setFocused = function ( value ) {
value = !!value;
if ( this.focused !== value ) {
this.focused = value;
if ( this.focused ) {
this.emit( 'focus' );
this.$element.addClass( 've-ce-focusableNode-focused' );
this.createHighlights();
// this.focused may have changed, so re-attach in the correct container
this.focusableSurface.appendHighlights( this.$highlights, this.focused );
this.focusableSurface.$element.off( '.ve-ce-focusableNode' );
} else {
this.emit( 'blur' );
this.$element.removeClass( 've-ce-focusableNode-focused' );
this.$highlights.removeClass( 've-ce-focusableNode-highlights-deactivated' );
this.clearHighlights();
}
}
};
/**
* Creates highlights.
*/
ve.ce.FocusableNode.prototype.createHighlights = function () {
if ( this.highlighted ) {
return;
}
this.$highlights.on( {
mousedown: this.onFocusableMouseDown.bind( this ),
dblclick: this.onFocusableDblClick.bind( this )
} );
this.highlighted = true;
this.positionHighlights();
this.focusableSurface.appendHighlights( this.$highlights, this.focused );
this.focusableSurface.connect( this, {
position: 'positionHighlights',
activation: 'onSurfaceActivation'
} );
// Events
if ( !this.focused ) {
this.focusableSurface.$element.on( {
'mousemove.ve-ce-focusableNode': this.onSurfaceMouseMove.bind( this ),
'mouseleave.ve-ce-focusableNode': this.onSurfaceMouseLeave.bind( this )
} );
}
};
/**
* Handle activation events from the surface
*/
ve.ce.FocusableNode.prototype.onSurfaceActivation = function () {
this.$highlights.toggleClass( 've-ce-focusableNode-highlights-deactivated', !!this.focusableSurface.isShownAsDeactivated() );
};
/**
* Clears highlight.
*/
ve.ce.FocusableNode.prototype.clearHighlights = function () {
if ( !this.highlighted ) {
return;
}
this.$highlights.remove().empty();
this.focusableSurface.$element.off( '.ve-ce-focusableNode' );
this.focusableSurface.disconnect( this );
this.highlighted = false;
this.boundingRect = null;
};
/**
* Redraws highlight.
*/
ve.ce.FocusableNode.prototype.redrawHighlights = function () {
if ( this.focused && this.focusableSurface ) {
// setFocused will call clearHighlights/createHighlights
// and also re-bind events.
this.setFocused( false );
this.setFocused( true );
}
};
/**
* Re-calculate position of highlights
*/
ve.ce.FocusableNode.prototype.calculateHighlights = function () {
// Protect against calling before/after surface setup/teardown
if ( !this.focusableSurface ) {
this.rects = [];
this.boundingRect = null;
this.startAndEndRects = null;
return;
}
const surfaceOffset = this.focusableSurface.getSurface().getBoundingClientRect();
const allRects = this.constructor.static.getRectsForElement( this.$focusable, surfaceOffset );
this.rects = allRects.rects;
this.boundingRect = allRects.boundingRect;
// startAndEndRects is lazily evaluated in getStartAndEndRects from rects
this.startAndEndRects = null;
};
/**
* Positions highlights, and remove collapsed ones
*/
ve.ce.FocusableNode.prototype.positionHighlights = function () {
if ( !this.highlighted ) {
return;
}
this.calculateHighlights();
this.$highlights.empty()
// Append something selectable for right-click copy
.append( $( '<span>' ).addClass( 've-ce-focusableNode-highlight-selectable' ).text( '\u00a0' ) );
for ( let i = 0, l = this.rects.length; i < l; i++ ) {
const $highlight = this.createHighlight();
this.$highlights.append(
$highlight.css( {
top: this.rects[ i ].top,
left: this.rects[ i ].left,
width: this.rects[ i ].width,
height: this.rects[ i ].height
} )
);
}
};
/**
* Get list of rectangles outlining the shape of the node relative to the surface
*
* @return {Object[]} List of rectangle objects
*/
ve.ce.FocusableNode.prototype.getRects = function () {
if ( !this.highlighted ) {
this.calculateHighlights();
}
return this.rects;
};
/**
* Get the bounding rectangle of the focusable node highlight relative to the surface
*
* @return {Object|null} Top, left, bottom & right positions of the focusable node relative to the surface
*/
ve.ce.FocusableNode.prototype.getBoundingRect = function () {
if ( !this.$bounding.is( this.$focusable ) ) {
const surfaceOffset = this.focusableSurface.getSurface().getBoundingClientRect();
const allRects = this.constructor.static.getRectsForElement( this.$bounding, surfaceOffset );
return allRects.boundingRect;
}
if ( !this.highlighted ) {
this.calculateHighlights();
}
return this.boundingRect;
};
/**
* Get start and end rectangles of an inline focusable node relative to the surface
*
* @return {Object.<string,Object>|null} Start and end rectangles
*/
ve.ce.FocusableNode.prototype.getStartAndEndRects = function () {
if ( !this.highlighted ) {
this.calculateHighlights();
}
if ( !this.startAndEndRects ) {
this.startAndEndRects = ve.getStartAndEndRects( this.rects );
}
return this.startAndEndRects;
};
/**
* Check if the rendering is visible
*
* "Visible", in this case, is defined as at least 10px × 4px
* or 4px × 10px in dimensions (T307527)
*
* @return {boolean} The node has a visible rendering
*/
ve.ce.FocusableNode.prototype.hasRendering = function () {
let visible = false;
function checkSize( width, height ) {
return ( width >= 10 && height >= 4 ) ||
( height >= 10 && width >= 4 );
}
this.$element.each( ( i, element ) => {
if (
checkSize( element.offsetWidth, element.offsetHeight ) ||
// Check width/height attribute as well. (T125767)
checkSize( element.width, element.height )
) {
visible = true;
return false;
}
} );
return visible;
};
/**
* Check if the node is inside a ve.ce.ContentEditableNode with editing disabled
*
* Ignore nodes which just disable CE in the DOM manually (e.g. TableNode)
* as focusables should still be highlightable in these.
*
* @return {boolean} Editing disabled
*/
ve.ce.FocusableNode.prototype.isInContentEditableDisabled = function () {
return !!this.traverseUpstream( ( node ) => !(
node.isContentEditable && !node.isContentEditable()
) );
};