/*!
* VisualEditor ContentEditable ContentBranchNode class.
*
* @copyright See AUTHORS.txt
*/
/**
* ContentEditable content branch node.
*
* Content branch nodes can only have content nodes as children.
*
* @abstract
* @extends ve.ce.BranchNode
* @constructor
* @param {ve.dm.BranchNode} model Model to observe
* @param {Object} [config] Configuration options
*/
ve.ce.ContentBranchNode = function VeCeContentBranchNode() {
// Properties
this.lastTransaction = null;
// Parent constructor calls renderContents, so this must be set first
this.rendered = false;
this.unicornAnnotations = null;
this.unicorns = null;
// Parent constructor
ve.ce.ContentBranchNode.super.apply( this, arguments );
this.onClickHandler = this.onClick.bind( this );
// Events
this.connect( this, { childUpdate: 'onChildUpdate' } );
this.model.connect( this, { detach: 'onModelDetach' } );
// Some browsers allow clicking links inside contenteditable, such as in iOS Safari when the
// keyboard is closed
this.$element.on( 'click', this.onClickHandler );
};
/* Inheritance */
OO.inheritClass( ve.ce.ContentBranchNode, ve.ce.BranchNode );
/* Static Members */
/**
* @property {boolean} Whether Enter splits this node type. Must be true for ContentBranchNodes.
*
* Warning: overriding this to false in a subclass will cause crashes on Enter key handling.
*
* @static
* @inheritable
*/
ve.ce.ContentBranchNode.static.splitOnEnter = true;
ve.ce.ContentBranchNode.static.autoFocus = true;
/* Static Methods */
/**
* Append the return value of #getRenderedContents to a DOM element.
*
* @param {HTMLElement} container DOM element
* @param {HTMLElement} wrapper Wrapper returned by #getRenderedContents
*/
ve.ce.ContentBranchNode.static.appendRenderedContents = function ( container, wrapper ) {
function resolveOriginals( domElement ) {
for ( let i = 0, len = domElement.childNodes.length; i < len; i++ ) {
const child = domElement.childNodes[ i ];
if ( child.veOrigNode ) {
domElement.replaceChild( child.veOrigNode, child );
} else if ( child.childNodes && child.childNodes.length ) {
resolveOriginals( child );
}
}
}
/* Resolve references to the original nodes. */
resolveOriginals( wrapper );
while ( wrapper.firstChild ) {
container.appendChild( wrapper.firstChild );
}
};
/* Methods */
/**
* @inheritdoc
*/
ve.ce.ContentBranchNode.prototype.initialize = function () {
// Parent method
ve.ce.ContentBranchNode.super.prototype.initialize.call( this );
this.$element.addClass( 've-ce-contentBranchNode' );
};
/**
* @inheritdoc
*/
ve.ce.ContentBranchNode.prototype.onSetup = function () {
// Parent method
ve.ce.ContentBranchNode.super.prototype.onSetup.apply( this, arguments );
// DOM changes (duplicated from constructor in case this.$element is replaced)
this.$element.addClass( 've-ce-contentBranchNode' );
};
/**
* Handle click events.
*
* @param {jQuery.Event} e Click event
*/
ve.ce.ContentBranchNode.prototype.onClick = function ( e ) {
if (
// Only block clicks on links
( e.target !== this.$element[ 0 ] && e.target.nodeName.toUpperCase() === 'A' ) &&
// Don't prevent a modified click, which in some browsers deliberately opens the link
( !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey )
) {
e.preventDefault();
}
};
/**
* Handle childUpdate events.
*
* Rendering is only done once per transaction. If a paragraph has multiple nodes in it then it's
* possible to receive multiple `childUpdate` events for a single transaction such as annotating
* across them. State is tracked by storing and comparing the length of the surface model's complete
* history.
*
* This is used to automatically render contents.
*
* @param {ve.dm.Transaction} transaction
*/
ve.ce.ContentBranchNode.prototype.onChildUpdate = function ( transaction ) {
if ( transaction === null || transaction === this.lastTransaction ) {
this.lastTransaction = transaction;
return;
}
this.renderContents();
};
/**
* @inheritdoc
*/
ve.ce.ContentBranchNode.prototype.onSplice = function ( index, deleteCount, ...nodes ) {
// Parent method
ve.ce.ContentBranchNode.super.prototype.onSplice.apply( this, arguments );
// FIXME T126025: adjust slugNodes indexes if isRenderingLocked. This should be
// sufficient to keep this.slugNodes valid - only text changes can occur, which
// cannot create a requirement for a new slug (it can make an existing slug
// redundant, but it is harmless to leave it there).
// TODO fix the use of ve.ce.DocumentNode and getSurface
if (
this.root instanceof ve.ce.DocumentNode &&
this.root.getSurface().isRenderingLocked
) {
this.slugNodes.splice( index, deleteCount, ...new Array( nodes.length ) );
}
// Rerender to make sure annotations are applied correctly
this.renderContents();
};
/**
* @inheritdoc
*/
ve.ce.ContentBranchNode.prototype.setupBlockSlugs = function () {
// Respect render lock
// TODO: Can this check be moved into the parent method?
// TODO fix the use of ve.ce.DocumentNode and getSurface
if (
this.root instanceof ve.ce.DocumentNode &&
this.root.getSurface().isRenderingLocked()
) {
return;
}
// Parent method
ve.ce.ContentBranchNode.super.prototype.setupBlockSlugs.apply( this, arguments );
};
/**
* @inheritdoc
*/
ve.ce.ContentBranchNode.prototype.setupInlineSlugs = function () {
// Respect render lock
// TODO: Can this check be moved into the parent method?
// TODO fix the use of ve.ce.DocumentNode and getSurface
if (
this.root instanceof ve.ce.DocumentNode &&
this.root.getSurface().isRenderingLocked()
) {
return;
}
// Parent method
ve.ce.ContentBranchNode.super.prototype.setupInlineSlugs.apply( this, arguments );
};
/**
* @typedef {Object} UnicornInfo
* @memberof ve.ce.ContentBranchNode
* @property {boolean} hasCursor
* @property {ve.dm.AnnotationSet|null} annotations
* @property {HTMLElement[]|null} unicorns
*/
/**
* @typedef {HTMLElement} HTMLElementWithUnicorn
* @memberof ve.ce.ContentBranchNode
* @property {ve.ce.ContentBranchNode.UnicornInfo} unicornInfo Unicorn information
*/
/**
* Get an HTML rendering of the contents.
*
* If you are actually going to append the result to a DOM, you need to
* do this with #appendRenderedContents, which resolves the cloned
* nodes returned by this function back to their originals.
*
* @return {ve.ce.ContentBranchNode.HTMLElementWithUnicorn} Wrapper containing rendered contents
*/
ve.ce.ContentBranchNode.prototype.getRenderedContents = function () {
const store = this.model.doc.getStore(),
annotationSet = new ve.dm.AnnotationSet( store ),
doc = this.getElementDocument(),
wrapper = doc.createElement( 'div' ),
annotatedHtml = [],
annotationStack = [],
nodeStack = [],
unicornInfo = {
hasCursor: false,
annotations: null,
unicorns: null
};
let annotationsChanged,
current = wrapper,
buffer = '';
// Source mode optimization
if ( this.getModel().getDocument().sourceMode ) {
wrapper.appendChild(
document.createTextNode(
this.getModel().getDocument().getDataFromNode( this.getModel() ).join( '' )
)
);
wrapper.unicornInfo = unicornInfo;
return wrapper;
}
const openAnnotation = ( annotation ) => {
annotationsChanged = true;
if ( buffer !== '' ) {
if ( current.nodeType === Node.TEXT_NODE ) {
current.textContent += buffer;
} else {
current.appendChild( doc.createTextNode( buffer ) );
}
buffer = '';
}
// Create a new DOM node and descend into it
annotation.store = store;
const ann = ve.ce.annotationFactory.create( annotation.getType(), annotation, this );
ann.appendTo( current );
annotationStack.push( ann );
nodeStack.push( current );
current = ann.getContentContainer();
};
const closeAnnotation = () => {
annotationsChanged = true;
if ( buffer !== '' ) {
if ( current.nodeType === Node.TEXT_NODE ) {
current.textContent += buffer;
} else {
current.appendChild( doc.createTextNode( buffer ) );
}
buffer = '';
}
// Traverse up
const ann = annotationStack.pop();
ann.attachContents();
current = nodeStack.pop();
};
// Gather annotated HTML from the child nodes
for ( let i = 0, ilen = this.children.length; i < ilen; i++ ) {
ve.batchPush( annotatedHtml, this.children[ i ].getAnnotatedHtml() );
}
// Set relCursor to collapsed selection offset, or -1 if none
// (in which case we don't need to worry about preannotation)
let relCursor = -1;
let dmSurface;
if ( this.getRoot() ) {
const ceSurface = this.getRoot().getSurface();
dmSurface = ceSurface.getModel();
// TODO: dmSurface is used again later but might not be set
const dmSelection = dmSurface.getTranslatedSelection();
if ( dmSelection instanceof ve.dm.LinearSelection && dmSelection.isCollapsed() ) {
// Subtract 1 for CBN opening tag
relCursor = dmSelection.getRange().start - this.getOffset() - 1;
}
}
let unicorn;
// Set cursor status for renderContents. If hasCursor, splice unicorn marker at the
// collapsed selection offset. It will be rendered later if it is needed, else ignored
if ( relCursor < 0 || relCursor > this.getLength() ) {
unicornInfo.hasCursor = false;
} else {
unicornInfo.hasCursor = true;
let offset = 0;
let i, ilen;
for ( i = 0, ilen = annotatedHtml.length; i < ilen; i++ ) {
const htmlItem = annotatedHtml[ i ][ 0 ];
const childLength = ( typeof htmlItem === 'string' ) ? 1 : 2;
if ( offset <= relCursor && relCursor < offset + childLength ) {
unicorn = [
{}, // Unique object, for testing object equality later
dmSurface.getInsertionAnnotations().storeHashes
];
annotatedHtml.splice( i, 0, unicorn );
break;
}
offset += childLength;
}
// Special case for final position
if ( i === ilen && offset === relCursor ) {
unicorn = [
{}, // Unique object, for testing object equality later
dmSurface.getInsertionAnnotations().storeHashes
];
annotatedHtml.push( unicorn );
}
}
// Render HTML with annotations
for ( let i = 0, ilen = annotatedHtml.length; i < ilen; i++ ) {
let item;
let itemAnnotations;
if ( Array.isArray( annotatedHtml[ i ] ) ) {
item = annotatedHtml[ i ][ 0 ];
itemAnnotations = new ve.dm.AnnotationSet( store, annotatedHtml[ i ][ 1 ] );
} else {
item = annotatedHtml[ i ];
itemAnnotations = new ve.dm.AnnotationSet( store );
}
// annotationsChanged gets set to true by openAnnotation and closeAnnotation
annotationsChanged = false;
ve.dm.Converter.static.openAndCloseAnnotations( annotationSet, itemAnnotations,
openAnnotation, closeAnnotation
);
// Handle the actual item
if ( typeof item === 'string' ) {
buffer += item;
} else if ( unicorn && item === unicorn[ 0 ] ) {
if ( annotationsChanged ) {
if ( buffer !== '' ) {
current.appendChild( doc.createTextNode( buffer ) );
buffer = '';
}
const preUnicorn = doc.createElement( 'img' );
const postUnicorn = doc.createElement( 'img' );
preUnicorn.className = 've-ce-unicorn ve-ce-pre-unicorn';
postUnicorn.className = 've-ce-unicorn ve-ce-post-unicorn';
$( preUnicorn ).data( 'modelOffset', ( this.getOffset() + 1 + i ) );
$( postUnicorn ).data( 'modelOffset', ( this.getOffset() + 1 + i ) );
if ( ve.inputDebug ) {
preUnicorn.setAttribute( 'src', ve.ce.unicornImgDataUri );
postUnicorn.setAttribute( 'src', ve.ce.unicornImgDataUri );
preUnicorn.className += ' ve-ce-unicorn-debug';
postUnicorn.className += ' ve-ce-unicorn-debug';
} else {
preUnicorn.setAttribute( 'src', ve.ce.minImgDataUri );
postUnicorn.setAttribute( 'src', ve.ce.minImgDataUri );
}
current.appendChild( preUnicorn );
current.appendChild( postUnicorn );
unicornInfo.annotations = dmSurface.getInsertionAnnotations();
unicornInfo.unicorns = [ preUnicorn, postUnicorn ];
} else {
unicornInfo.annotations = null;
unicornInfo.unicorns = null;
}
} else {
if ( buffer !== '' ) {
current.appendChild( doc.createTextNode( buffer ) );
buffer = '';
}
// DOM equivalent of $( current ).append( item.clone() );
for ( let j = 0, jlen = item.length; j < jlen; j++ ) {
// Append a clone so as to not relocate the original node
const clone = item[ j ].cloneNode( true );
// Store a reference to the original node in a property
clone.veOrigNode = item[ j ];
current.appendChild( clone );
}
}
}
if ( buffer !== '' ) {
current.appendChild( doc.createTextNode( buffer ) );
buffer = '';
}
while ( annotationStack.length > 0 ) {
closeAnnotation();
}
wrapper.unicornInfo = unicornInfo;
return wrapper;
};
ve.ce.ContentBranchNode.prototype.onModelDetach = function () {
// TODO fix the use of ve.ce.DocumentNode and getSurface
if ( this.root instanceof ve.ce.DocumentNode ) {
this.root.getSurface().setContentBranchNodeChanged();
}
};
/**
* Render contents.
*
* @return {boolean} Whether the contents have changed
*/
ve.ce.ContentBranchNode.prototype.renderContents = function () {
// TODO fix the use of ve.ce.DocumentNode and getSurface
if (
this.root instanceof ve.ce.DocumentNode &&
this.root.getSurface().isRenderingLocked()
) {
return false;
}
if ( this.root instanceof ve.ce.DocumentNode ) {
this.root.getSurface().setContentBranchNodeChanged();
}
let rendered = this.getRenderedContents();
const unicornInfo = rendered.unicornInfo;
// Return if unchanged. Test by building the new version and checking DOM-equality.
// However we have to normalize to cope with consecutive text nodes. We can't normalize
// the attached version, because that would close IMEs. As an optimization, don't perform
// this checking if this node has never rendered before.
if ( this.rendered ) {
const oldWrapper = this.$element[ 0 ].cloneNode( true );
$( oldWrapper )
.find( '.ve-ce-annotation-active' )
.removeClass( 've-ce-annotation-active' );
$( oldWrapper )
.find( '.ve-ce-branchNode-inlineSlug' )
.children()
.unwrap()
.filter( '.ve-ce-chimera' )
.remove();
const newWrapper = this.$element[ 0 ].cloneNode( false );
while ( rendered.firstChild ) {
newWrapper.appendChild( rendered.firstChild );
}
oldWrapper.normalize();
newWrapper.normalize();
if ( newWrapper.isEqualNode( oldWrapper ) ) {
return false;
}
rendered = newWrapper;
}
this.rendered = true;
this.unicornAnnotations = unicornInfo.annotations || null;
this.unicorns = unicornInfo.unicorns || null;
// Detach all child nodes from this.$element
for ( let i = 0, len = this.$element.length; i < len; i++ ) {
const element = this.$element[ i ];
while ( element.firstChild ) {
element.removeChild( element.firstChild );
}
}
// Reattach nodes
this.constructor.static.appendRenderedContents( this.$element[ 0 ], rendered );
// Set unicorning status
if ( this.getRoot() ) {
if ( !unicornInfo.hasCursor ) {
this.getRoot().getSurface().setNotUnicorning( this );
} else if ( this.unicorns ) {
this.getRoot().getSurface().setUnicorning( this );
} else {
this.getRoot().getSurface().setNotUnicorningAll( this );
}
}
// Add slugs
this.setupInlineSlugs();
// Highlight the node in debug mode
if ( ve.inputDebug ) {
this.$element.css( 'backgroundColor', '#eee' );
setTimeout( () => {
this.$element.css( 'backgroundColor', '' );
}, 300 );
}
return true;
};
/**
* @inheritdoc
*/
ve.ce.ContentBranchNode.prototype.detach = function () {
if ( this.getRoot() ) {
// This should be true, as the root is removed in the parent detach
// method which hasn't run yet. However, just in case a node gets
// double-detached…
this.getRoot().getSurface().setNotUnicorning( this );
}
// Parent method
ve.ce.ContentBranchNode.super.prototype.detach.call( this );
};
/**
* @inheritdoc
*/
ve.ce.ContentBranchNode.prototype.destroy = function () {
this.$element.off( 'click', this.onClickHandler );
// Parent method
ve.ce.ContentBranchNode.super.prototype.destroy.call( this );
};