/*!
 * 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 = {} ) {
	// 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;
	}

	if ( relativeRect ) {
		rects.forEach( ( rect, i ) => {
			rects[ i ] = ve.translateRect( rect, -relativeRect.left, -relativeRect.top );
		} );
	}

	const boundingRect = ve.getBoundingRect( rects );

	return { rects, 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 ve-ce-surface-interface' )
			.prepend( this.icon.$element );
	} else if ( this.icon ) {
		this.$element.removeClass( 've-ce-focusableNode-invisible ve-ce-surface-interface' );
		this.icon.$element.detach();
	}
};

/**
 * Create a element to show if the node is invisible
 */
ve.ce.FocusableNode.prototype.createInvisibleIcon = function () {
	if ( this.icon ) {
		return;
	}
	// Label is set in updateInvisibleIconLabel
	// eslint-disable-next-line mediawiki/no-unlabeled-buttonwidget
	this.icon = new OO.ui.ButtonWidget( {
		classes: [ 've-ce-focusableNode-invisibleIcon' ],
		framed: false,
		// Make button unfocusable, T198912
		tabIndex: null,
		icon: this.constructor.static.iconWhenInvisible
	} );
	// Add an invisible image to the button, which is expanded to cover the
	// whole area, so that when selected the element is highlighted (T390733).
	this.icon.$element.append(
		$( '<img>' )
			.addClass( 've-ce-focusableNode-invisibleIcon-selectionMask' )
			.attr( 'src', ve.ce.minImgDataUri )
	);
	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' ) );

	this.rects.forEach( ( rect ) => {
		const $highlight = this.createHighlight();
		this.$highlights.append(
			$highlight.css( {
				top: rect.top,
				left: rect.left,
				width: rect.width,
				height: rect.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()
	) );
};
