jenkins-bot has submitted this change and it was merged. Change subject: Inflict a gruesome death on ve.ce.getOffset() and reincarnate it ......................................................................
Inflict a gruesome death on ve.ce.getOffset() and reincarnate it The old ve.ce.getOffset() code seemed to work but was labyrinthine and incomprehensible. This is a rewrite from scratch that should be easier to deal with. Behavioral differences that the tests had to be changed for: * The last cursor position no longer magically jumps over the internalList. * Cursor positions inside of inline nodes now map to the offset inside the inline node, rather than the offset after it. Change-Id: I324e42af4c87379ff2dee21e40b91fcf911f6769 --- M modules/ve/ce/ve.ce.js M modules/ve/test/ce/ve.ce.test.js 2 files changed, 142 insertions(+), 129 deletions(-) Approvals: Divec: Looks good to me, approved jenkins-bot: Verified diff --git a/modules/ve/ce/ve.ce.js b/modules/ve/ce/ve.ce.js index 8e8754a..4c0e50c 100644 --- a/modules/ve/ce/ve.ce.js +++ b/modules/ve/ce/ve.ce.js @@ -118,144 +118,156 @@ * @param {HTMLElement} domNode DOM node * @param {number} domOffset DOM offset within the DOM node * @returns {number} Linear model offset + * @throws {Error} domOffset is out of bounds + * @throws {Error} domNode has no ancestor with a .data( 'view' ) */ ve.ce.getOffset = function ( domNode, domOffset ) { - if ( domNode.nodeType === Node.TEXT_NODE ) { - return ve.ce.getOffsetFromTextNode( domNode, domOffset ); + var node, view, offset, startNode, maxOffset, lengthSum = 0; + + /** + * Move to the previous "traversal node" in "traversal sequence". + * + * - A node is a "traversal node" if it is either a leaf node or a "view node" + * - A "view node" is one that has $( n ).data( 'view' ) instanceof ve.ce.Node + * - "Traversal sequence" is defined on every node (not just traversal nodes). + * It is like document order, except that each parent node appears + * in the sequence both immediately before and immediately after its child nodes. + * + * Important properties: + * - Non-traversal nodes don't have any width in DM (e.g. bold). + * - Certain traversal nodes also have no width (namely, those within an alienated node). + * - Both the start and end of a (non-alienated) parent traversal node has width + * (which is one reason why traversal sequence is important). + * - In VE-normalized HTML, a text node cannot be a sibling of a non-leaf view node + * (because all non-alienated text nodes are inside a ContentBranchNode). + * - Traversal-consecutive non-view nodes are either all alienated or all not alienated. + * + * @param {Node} n Node to traverse from + * @returns {Node} Previous traversal node from n + * @throws {Error} domNode has no ancestor with a .data( 'view' ) + */ + function traverse( n ) { + while ( !n.previousSibling ) { + n = n.parentNode; + if ( !n ) { + throw new Error( 'domNode has no ancestor with a .data( \'view\' )' ); + } + if ( $( n ).data( 'view' ) instanceof ve.ce.Node ) { + return n; + } + } + n = n.previousSibling; + if ( $( n ).data( 'view' ) instanceof ve.ce.Node ) { + return n; + } + while ( n.lastChild ) { + n = n.lastChild; + if ( $( n ).data( 'view' ) instanceof ve.ce.Node ) { + return n; + } + } + return n; + } + + // Validate domOffset + if ( domNode.nodeType === Node.ELEMENT_NODE ) { + maxOffset = domNode.childNodes.length; } else { - return ve.ce.getOffsetFromElementNode( domNode, domOffset ); + maxOffset = domNode.data.length; } -}; - -/** - * Gets the linear offset from a given text node and offset within it. - * - * @method - * @param {HTMLElement} domNode DOM node - * @param {number} domOffset DOM offset within the DOM Element - * @returns {number} Linear model offset - */ -ve.ce.getOffsetFromTextNode = function ( domNode, domOffset ) { - var $node, nodeModel, current, stack, item, offset, $item; - - $node = $( domNode ).closest( - '.ve-ce-branchNode, .ve-ce-leafNode' - ); - nodeModel = $node.data( 'view' ).getModel(); - - // IE sometimes puts the cursor in a text node inside ce="false". BAD! - if ( $node[0].contentEditable === 'false' ) { - return nodeModel.getOffset() + nodeModel.getOuterLength(); + if ( domOffset < 0 || domOffset > maxOffset) { + throw new Error( 'domOffset is out of bounds' ); } - if ( !$node.hasClass( 've-ce-branchNode' ) ) { - return nodeModel.getOffset(); - } - - current = [$node.contents(), 0]; - stack = [current]; - offset = 0; - - while ( stack.length > 0 ) { - if ( current[1] >= current[0].length ) { - stack.pop(); - current = stack[ stack.length - 1 ]; - continue; - } - item = current[0][current[1]]; - if ( item.nodeType === Node.TEXT_NODE ) { - if ( item === domNode ) { - offset += domOffset; - break; - } else { - offset += item.textContent.length; + // Figure out what node to start traversing at (startNode) + if ( domNode.nodeType === Node.ELEMENT_NODE ) { + if ( domNode.childNodes.length === 0 ) { + // domNode has no children, and the offset is inside of it + // If domNode is a view node, return the offset inside of it + // Otherwise, start traversing at domNode + startNode = domNode; + view = $( startNode ).data( 'view' ); + if ( view instanceof ve.ce.Node ) { + return view.getOffset() + ( view.isWrapped() ? 1 : 0 ); } - } else if ( item.nodeType === Node.ELEMENT_NODE ) { - $item = current[0].eq( current[1] ); - if ( $item.hasClass( 've-ce-branchNode-slug' ) ) { - if ( $item.contents()[0] === domNode ) { - break; - } - } else if ( $item.hasClass( 've-ce-leafNode' ) ) { - offset += 2; - } else if ( $item.hasClass( 've-ce-branchNode' ) ) { - offset += $item.data( 'view' ).getOuterLength(); - } else { - stack.push( [ $item.contents(), 0 ] ); - current[1]++; - current = stack[ stack.length - 1 ]; - continue; + node = startNode; + } else if ( domOffset === domNode.childNodes.length ) { + // Offset is at the end of domNode, after the last child. Set startNode to the + // very rightmost descendant node of domNode (i.e. the last child of the last child + // of the last child, etc.) + // However, if the last child or any of the last children we encounter on the way + // is a view node, return the offset after it. This will be the correct return value + // because non-traversal nodes don't have a DM width. + startNode = domNode.lastChild; + + view = $( startNode ).data( 'view' ); + if ( view instanceof ve.ce.Node ) { + return view.getOffset() + view.getOuterLength(); } - } - current[1]++; - } - return offset + nodeModel.getOffset() + ( nodeModel.isWrapped() ? 1 : 0 ); -}; - -/** - * Gets the linear offset from a given element node and offset within it. - * - * @method - * @param {HTMLElement} domNode DOM node - * @param {number} domOffset DOM offset within the DOM Element - * @param {number} [direction] Which direction we are searching in (+/-1), not changed by recursive calls - * @returns {number} Linear model offset - */ -ve.ce.getOffsetFromElementNode = function ( domNode, domOffset, direction ) { - var nodeModel, node, - $domNode = $( domNode ); - - if ( $domNode.hasClass( 've-ce-branchNode-slug' ) ) { - if ( $domNode.prev().length ) { - nodeModel = $domNode.prev().data( 'view' ).getModel(); - return nodeModel.getOffset() + nodeModel.getOuterLength(); - } - if ( $domNode.next().length ) { - nodeModel = $domNode.next().data( 'view' ).getModel(); - return nodeModel.getOffset(); - } - } - - // IE sometimes puts the cursor in a text node inside ce="false". BAD! - if ( !direction && !domNode.isContentEditable ) { - nodeModel = $domNode.closest( '.ve-ce-branchNode, .ve-ce-leafNode' ).data( 'view' ).getModel(); - return nodeModel.getOffset() + nodeModel.getOuterLength(); - } - - if ( domOffset === 0 || domOffset === domNode.childNodes.length ) { - node = $domNode.data( 'view' ); - if ( node && node instanceof ve.ce.Node ) { - nodeModel = $domNode.data( 'view' ).getModel(); - if ( direction === -1 ) { - return nodeModel.getOffset() + nodeModel.getOuterLength(); - } else if ( direction === 1 ) { - return nodeModel.getOffset(); - } else { - if ( domOffset === 0 ) { - return nodeModel.getOffset() + ( nodeModel.isWrapped() ? 1 : 0 ); - } else { - return nodeModel.getOffset() + nodeModel.getOuterLength() - ( nodeModel.isWrapped() ? 1 : 0 ); + while ( startNode.lastChild ) { + startNode = startNode.lastChild; + view = $( startNode ).data( 'view' ); + if ( view instanceof ve.ce.Node ) { + return view.getOffset() + view.getOuterLength(); } } + node = startNode; } else { - if ( domOffset === 0 ) { - node = $domNode.contents().first()[0]; - direction = direction || 1; - } else { - node = $domNode.contents().last()[0]; - direction = direction || -1; - } + // Offset is right before childNodes[domOffset]. Set startNode to this node + // (i.e. the node right after the offset), then traverse back once. + startNode = domNode.childNodes[domOffset]; + node = traverse( startNode ); } } else { - node = $domNode.contents()[ domOffset - 1 ]; - direction = direction || -1; + lengthSum += domOffset; + startNode = domNode; + node = traverse( startNode ); } - if ( node.nodeType === Node.TEXT_NODE ) { - return ve.ce.getOffsetFromTextNode( node, direction > 0 ? 0 : node.length ); - } else { - return ve.ce.getOffsetFromElementNode( node, direction > 0 ? 0 : node.childNodes.length, direction ); + // Walk the traversal nodes in reverse traversal sequence, until we find a view node. + // Add the width of each text node we meet. (Non-text node non-view nodes can only be widthless). + // Later, if it transpires that we're inside an alienated node, then we will throw away all the + // text node lengths, because the alien's content has no DM width. + while ( true ) { + // First node that has a ve.ce.Node, stop + // Note that annotations have a .data( 'view' ) too, but that's a ve.ce.Annotation, + // not a ve.ce.Node + view = $( node ).data( 'view' ); + if ( view instanceof ve.ce.Node ) { + break; + } + + if ( node.nodeType === Node.TEXT_NODE ) { + lengthSum += node.data.length; + } + // else: non-text nodes that don't have a .data( 'view' ) don't exist in the DM + node = traverse( node ); } + + offset = view.getOffset(); + + if ( $.contains( node, startNode ) ) { + // node is an ancestor of startNode + // Add 1 to take the opening into account + offset += view.getModel().isWrapped() ? 1 : 0; + if ( view.getModel().canContainContent() ) { + offset += lengthSum; + } + // else: we're inside an alienated node: throw away all the text node lengths, + // because the alien's content has no DM width + } else { + // node is not an ancestor of startNode + // startNode comes after node, so add node's length + offset += view.getOuterLength(); + if ( view.isContent() ) { + // view is a leaf node inside of a CBN, so we started inside of a CBN + // (otherwise we would have hit the CBN when entering it), so the text we summed up + // needs to be counted. + offset += lengthSum; + } + } + + return offset; }; /** diff --git a/modules/ve/test/ce/ve.ce.test.js b/modules/ve/test/ce/ve.ce.test.js index 20d4d17..246a57e 100644 --- a/modules/ve/test/ce/ve.ce.test.js +++ b/modules/ve/test/ce/ve.ce.test.js @@ -30,7 +30,7 @@ ); } ); -QUnit.test( 'getOffsetFrom(Element|Text)Node', function ( assert ) { +QUnit.test( 'getOffset', function ( assert ) { var i, surface, documentModel, documentView, expected = 0, testCases = [ @@ -43,7 +43,7 @@ 2, 3, 4, 4, 4, 4, - 7 + 5 ] }, { @@ -61,7 +61,7 @@ 8, 9, 10, 10, 10, 10, 10, - 13 + 11 ] }, { @@ -75,11 +75,12 @@ 2, 3, 4, 4, 4, - 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, + 5, 5, 5, 5, 5, 5, 5, 5, + 6, 6, 6, 7, 8, 9, 9, - 12 + 10 ] } ]; @@ -97,7 +98,7 @@ for ( i = 0; i <= parent.childNodes.length; i++ ) { expectedIndex++; assert.equal( - ve.ce.getOffsetFromElementNode( parent, i ), + ve.ce.getOffset( parent, i ), testCase.expected[expectedIndex], testCase.msg + ': offset ' + i + ' in <' + parent.nodeName.toLowerCase() + '>' ); @@ -110,7 +111,7 @@ for ( i = 0; i <= parent.data.length; i++ ) { expectedIndex++; assert.equal( - ve.ce.getOffsetFromTextNode( parent, i ), + ve.ce.getOffset( parent, i ), testCase.expected[expectedIndex], testCase.msg + ': offset ' + i + ' in "' + parent.data + '"' ); -- To view, visit https://gerrit.wikimedia.org/r/121563 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: I324e42af4c87379ff2dee21e40b91fcf911f6769 Gerrit-PatchSet: 12 Gerrit-Project: VisualEditor/VisualEditor Gerrit-Branch: master Gerrit-Owner: Catrope <roan.katt...@gmail.com> Gerrit-Reviewer: Catrope <roan.katt...@gmail.com> Gerrit-Reviewer: Divec <da...@sheetmusic.org.uk> Gerrit-Reviewer: Esanders <esand...@wikimedia.org> Gerrit-Reviewer: Inez <i...@wikia-inc.com> Gerrit-Reviewer: Jforrester <jforres...@wikimedia.org> Gerrit-Reviewer: jenkins-bot <> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits