/*!
* VisualEditor ContentEditable Document class.
*
* @copyright See AUTHORS.txt
*/
/**
* ContentEditable document.
*
* @class
* @extends ve.Document
*
* @constructor
* @param {ve.dm.Document} model Model to observe
* @param {ve.ce.Surface} surface Surface document is part of
*/
ve.ce.Document = function VeCeDocument( model, surface ) {
// Parent constructor
ve.ce.Document.super.call( this, new ve.ce.DocumentNode( model.getDocumentNode(), surface ) );
this.lang = null;
this.dir = null;
this.setLang( model.getLang() );
this.setDir( model.getDir() );
// Properties
this.model = model;
};
/* Inheritance */
OO.inheritClass( ve.ce.Document, ve.Document );
/* Events */
/**
* Language or direction changed
*
* @event ve.ce.Document#langChange
*/
/* Methods */
/**
* Set the document view language
*
* @param {string} lang Language code
*/
ve.ce.Document.prototype.setLang = function ( lang ) {
this.getDocumentNode().$element.prop( 'lang', lang );
this.lang = lang;
this.emit( 'langChange' );
};
/**
* Set the document view directionality
*
* @param {string} dir Directionality (ltr/rtl)
*/
ve.ce.Document.prototype.setDir = function ( dir ) {
this.getDocumentNode().$element.prop( 'dir', dir );
this.dir = dir;
this.emit( 'langChange' );
};
/**
* Get the document view language
*
* @return {string} Language code
*/
ve.ce.Document.prototype.getLang = function () {
return this.lang;
};
/**
* Get the document view directionality
*
* @return {string} Directionality (ltr/rtl)
*/
ve.ce.Document.prototype.getDir = function () {
return this.dir;
};
/**
* Get a slug at an offset.
*
* @param {number} offset Offset to get slug at
* @return {HTMLElement} Slug at offset
*/
ve.ce.Document.prototype.getSlugAtOffset = function ( offset ) {
const node = this.getBranchNodeFromOffset( offset );
return node ? node.getSlugAtOffset( offset ) : null;
};
/**
* Calculate the DOM position corresponding to a DM offset
*
* If there are multiple DOM locations, heuristically pick the best one for cursor placement
*
* @private
* @param {number} offset Linear model offset
* @return {ve.ce.NodeAndOffset} Position
* @throws {Error} Offset could not be translated to a DOM element and offset
*/
ve.ce.Document.prototype.getNodeAndOffset = function ( offset ) {
const countedNodes = [];
// 1. Step with ve.adjacentDomPosition( …, { stop: function () { return true; } } )
// until we hit a position at the correct offset (which is guaranteed to be the first
// such position in document order).
// 2. Use ve.adjacentDomPosition( …, { stop: … } ) once to return all
// subsequent positions at the same offset.
// 3. Look at the possible positions and pick as follows:
// - If there is a unicorn, return just inside it
// - Else if there is a nail, return just outside it
// - Else if there is a text node, return an offset in it
// - Else return the first matching offset
//
// Offsets of DOM nodes are counted to match their model equivalents.
//
// TODO: take the following into account:
// Unfortunately, there is no way to avoid slugless block nodes with no DM length: an
// IME can remove all the text from a node at a time when it is unsafe to fixup the node
// contents. In this case, a maximally deep element gives better bounding rectangle
// coordinates than any of its containers.
if ( !this.model.getDocumentRange().containsRange( new ve.Range( offset ) ) ) {
throw new Error( 'Offset is out of bounds' );
}
const branchNode = this.getBranchNodeFromOffset( offset );
let count = branchNode.getOffset() + ( branchNode.isWrapped() ? 1 : 0 );
let node;
if ( !( branchNode instanceof ve.ce.ContentBranchNode ) ) {
// The cursor does not lie in a ContentBranchNode, so we can determine
// everything from the DM tree
let i, ceChild;
for ( i = 0; ; i++ ) {
ceChild = branchNode.children[ i ];
if ( count === offset ) {
break;
}
if ( !ceChild ) {
throw new Error( 'Offset lies beyond branchNode' );
}
count += ceChild.getOuterLength();
if ( count > offset ) {
if ( ceChild.getOuterLength() !== 2 ) {
throw new Error( 'Offset lies inside child of strange size' );
}
node = ceChild.$element[ 0 ];
if ( node ) {
return { node: node, offset: 0 };
}
// Else ceChild has no DOM representation; step forwards
break;
}
}
// Offset lies directly in branchNode, just before ceChild
node = branchNode.$element[ 0 ];
while ( ceChild && !ceChild.$element[ 0 ] ) {
// Node does not have a DOM representation; move forwards past it
i++;
ceChild = branchNode.children[ i ];
}
if ( !ceChild || !ceChild.$element[ 0 ] ) {
// Offset lies just at the end of branchNode
return { node: node, offset: node.childNodes.length };
}
return {
node: node,
offset: Array.prototype.indexOf.call(
node.childNodes,
ceChild.$element[ 0 ]
)
};
}
// Else the cursor lies in a ContentBranchNode, so we must traverse the DOM, keeping
// count of the corresponding DM position until it reaches offset.
let position = { node: branchNode.$element[ 0 ], offset: 0 };
function noDescend() {
return this.classList.contains( 've-ce-branchNode-blockSlug' ) ||
ve.rejectsCursor( this );
}
while ( true ) {
if ( count === offset ) {
break;
}
position = ve.adjacentDomPosition(
position,
1,
{
noDescend: noDescend,
stop: function () {
return true;
}
}
);
const step = position.steps[ 0 ];
node = step.node;
if ( node.nodeType === Node.TEXT_NODE ) {
if ( step.type === 'leave' ) {
// Skip without incrementing
continue;
}
// else the code below always breaks or skips over the text node;
// therefore it is guaranteed that step.type === 'enter' (we just
// stepped in)
// TODO: what about zero-length text nodes?
if ( offset <= count + node.data.length ) {
// Match the appropriate offset in the text node
position = { node: node, offset: offset - count };
break;
} else {
// Skip over the text node
count += node.data.length;
position = { node: node, offset: node.data.length };
continue;
}
} // else it is an element node (TODO: handle comment etc)
if ( !(
node.classList.contains( 've-ce-branchNode' ) ||
node.classList.contains( 've-ce-leafNode' )
) ) {
// Nodes like b, inline slug, browser-generated br that doesn't have
// class ve-ce-leafNode: continue walk without incrementing
continue;
}
if ( step.type === 'leave' ) {
// Below we'll guarantee that .ve-ce-branchNode/.ve-ce-leafNode elements
// are only entered if their open/close tags take up a model offset, so
// we can increment unconditionally here
count++;
continue;
} // else step.type === 'enter' || step.type === 'cross'
const model = $.data( node, 'view' ).model;
if ( countedNodes.indexOf( model ) !== -1 ) {
// This DM node is rendered as multiple DOM elements, and we have already
// counted it as part of an earlier element. Skip past without incrementing
position = { node: node.parentNode, offset: ve.parentIndex( node ) + 1 };
continue;
}
countedNodes.push( model );
if ( offset >= count + model.getOuterLength() ) {
// Offset doesn't lie inside the node. Skip past and count length
// skip past the whole node
position = { node: node.parentNode, offset: ve.parentIndex( node ) + 1 };
count += model.getOuterLength();
} else if ( step.type === 'cross' ) {
if ( offset === count + 1 ) {
// The offset lies inside the crossed node
position = { node: node, offset: 0 };
break;
}
count += 2;
} else {
count += 1;
}
}
// Now "position" is the first DOM position (in document order) at the correct
// model offset.
// If the position is exactly after the first of multiple view nodes sharing a model,
// then jump to the position exactly after the final such view node.
const prevNode = position.node.childNodes[ position.offset - 1 ];
if ( prevNode && prevNode.nodeType === Node.ELEMENT_NODE && (
prevNode.classList.contains( 've-ce-branchNode' ) ||
prevNode.classList.contains( 've-ce-leafNode' )
) ) {
const $viewNodes = $.data( prevNode, 'view' ).$element;
if ( $viewNodes.length > 1 ) {
position.node = $viewNodes.get( -1 ).parentNode;
position.offset = 1 + ve.parentIndex( $viewNodes.get( -1 ) );
}
}
// Find all subsequent DOM positions at the same model offset
const found = {};
function stop( s ) {
let m;
if ( s.node.nodeType === Node.TEXT_NODE ) {
return s.type === 'internal';
}
if (
s.node.classList.contains( 've-ce-branchNode' ) ||
s.node.classList.contains( 've-ce-leafNode' )
) {
m = $.data( s.node, 'view' ).model;
if ( countedNodes.indexOf( m ) !== -1 ) {
return false;
}
countedNodes.push( m );
return true;
}
return false;
}
const steps = ve.adjacentDomPosition( position, 1, { stop: stop, noDescend: noDescend } ).steps;
steps.slice( 0, -1 ).forEach( ( s ) => {
// Step type cannot be "internal", else the offset would have incremented
const hasClass = function ( className ) {
return s.node.nodeType === Node.ELEMENT_NODE &&
s.node.classList.contains( className );
};
found.preUnicorn = found.preUnicorn || ( hasClass( 've-ce-pre-unicorn' ) && s );
found.postUnicorn = found.postUnicorn || ( hasClass( 've-ce-post-unicorn' ) && s );
found.preOpenNail = found.preOpenNail || ( hasClass( 've-ce-nail-pre-open' ) && s );
found.postOpenNail = found.postOpenNail || ( hasClass( 've-ce-nail-post-open' ) && s );
found.preCloseNail = found.preCloseNail || ( hasClass( 've-ce-nail-pre-close' ) && s );
found.postCloseNail = found.postCloseNail || ( hasClass( 've-ce-nail-post-close' ) && s );
found.focusableNode = found.focusableNode || ( hasClass( 've-ce-focusableNode' ) && s );
found.text = found.text || ( s.node.nodeType === Node.TEXT_NODE && s );
} );
// If there is a unicorn, it should be a unique pre/post-Unicorn pair containing text or
// nothing return the position just inside.
if ( found.preUnicorn ) {
return ve.ce.nextCursorOffset( found.preUnicorn.node );
}
if ( found.postUnicorn ) {
return ve.ce.previousCursorOffset( found.postUnicorn.node );
}
if ( found.preOpenNail ) {
// This will also cover the case where there is a post-open nail, as there will
// be no offset difference between them
return ve.ce.previousCursorOffset( found.preOpenNail.node );
}
if ( found.postCloseNail ) {
// This will also cover the case where there is a pre-close nail, as there will
// be no offset difference between them
return ve.ce.nextCursorOffset( found.postCloseNail.node );
}
if ( found.text ) {
if ( position.node.nodeType === Node.TEXT_NODE ) {
return position;
}
// We must either have entered or left the text node
return { node: found.text.node, offset: 0 };
}
return position;
};
/**
* Get the block directionality of some range
*
* Uses the computed CSS direction value of the current node
*
* @param {ve.Range} range
* @return {string} 'rtl', 'ltr'
*/
ve.ce.Document.prototype.getDirectionalityFromRange = function ( range ) {
const selectedNodes = this.selectNodes( range, 'covered' );
let effectiveNode;
if ( selectedNodes.length > 1 ) {
// Selection of multiple nodes
// Get the common parent node
effectiveNode = this.selectNodes( range, 'siblings' )[ 0 ].node.getParent();
} else {
// Selection of a single node
effectiveNode = selectedNodes[ 0 ].node;
while ( effectiveNode.isContent() ) {
// This means that we're in a leaf node, like TextNode
// those don't read the directionality properly, we will
// have to climb up the parentage chain until we find a
// wrapping node like paragraph or list item, etc.
effectiveNode = effectiveNode.parent;
}
}
return effectiveNode.$element.css( 'direction' );
};