0

Given a parent element, text offset, and length.. I'd like to be able to wrap an element around the text starting with the offset and going to the offset+length. If this text spans in our out of child elements, I'd like them to be split if the elements are spans... and cancelled (no changes made) if anything else besides spans or if we run out of space in the parent.

For example, given:

<div id='parent'>Aa bb <span class='child'>cc dd</span> ee ff</div>

If the offset and length are 4 and 5 (which would mean "bb cc"), I'd like to end up with:

<div id='parent'>Aa <span class='new'>bb <span class='child'>cc</span></span><span class='child'> dd</span> ee ff</div>

Notice that the .child element was split so that 'bb' and ' cc' are still within .child elements although only 'bb' was added to .new

Similarly for "dd ee", or various selections if there are further (more complex) nestings of child spans.

I'm having some trouble trying to wrap my head around how to do this and the only splitting I'm getting is a splitting headache.

I'm thinking a good function signature would be something like splitInsert(parentElement, textOffset, length)

Smern
  • 18,746
  • 21
  • 72
  • 90

2 Answers2

0

It looks like you want to wrap a length of characters at a given position in a span.

Here is a pseudo-code process to achieve this:

  1. Find the text start and end positions within the DOM ignoring any element markup.
  2. Split any elements that are only partially contained in the range.
  3. Create a containing element for the range.

Here is an answer with code samples for this approach. It selects the range by a regex match, so you would need to change it to be by index and length, but this should be enough to get you going.

How to wrap part of a text in a node with JavaScript

Community
  • 1
  • 1
thaliaarchi
  • 152
  • 2
  • 8
0

I managed to throw something together... took a bit more than I originally thought.

I created the following functions:

/**
 * Find the text node and the index within that given a parent node and the index within that.
 *
 * @param parentNode
 * @param index
 * @returns {*} - object with 'target' property set to the text node at the index parameter within the
 *  parentNode parameter and 'index' property set to the index of that point within 'target'
 */
findStartPoint = function(parentNode, index) {
    var nodeRight = 0;
    var nodeLeft = 0;
    var node = null;
    for(var i = 0; i < parentNode.childNodes.length; i++){
        node = parentNode.childNodes.item(i);
        if(node.nodeType !== 7 && node.nodeType !== 8){ //not processing instruction or comment
            if(nodeRight <= index){
                nodeLeft = nodeRight;
                nodeRight += node.text.length;
                if(nodeRight > index){
                    if (node.nodeType === 3) {
                        return { target: node, index: index-nodeLeft };
                    } else {
                        return this.findStartPoint( node, index-nodeLeft );
                    }
                }
            }
        }
    }
    return { target: null, index: null };
};

/**
 *
 * Inserts an element within a givin range, will split tags if necessary
 *
 * xx <bold>xx foo <italic> bar</italic></bold> baz xx
 *
 * If I selected 'foo bar baz' in the above:
 * - startPoint would be { target: the text node containing 'xx foo ', index: 4 }
 * - length would be 'foo bar baz'.length
 * - splittableEles could be ['BOLD', 'ITALIC']
 * - insert ele could be <hello>
 *
 * Output would be:
 * xx <bold>xx </bold><hello><bold>foo <italic> bar</italic></bold> baz</hello> xx
 *
 * @param startPoint - an object containing target (text node at beginning of split) and index (index of beginning within this text node)
 * @param length - length of selection in characters
 * @param splittableEles - elements that we allow to be split
 * @param insertEle - element that we will wrap the split within and insert
 * @returns {*}
 */
splitInsert = function(startPoint, length, splittableEles, insertEle) {
    var target = startPoint.target;
    var index = startPoint.index;

   if (index == 0 && $(target.parentNode).text().length <= length) {
        //consume entire target parent
        target.parentNode.parentNode.insertBefore(insertEle, target.parentNode);
        insertEle.appendChild(target.parentNode);
    } else {
       //split and add right of index to insertEle
       var content = target.splitText(index);
       content.parentNode.insertBefore(insertEle, content);
       if (content.length > length) {
           //split off the end if content longer than selection
           content.splitText(length);
       }
       insertEle.appendChild(content);
   }

    while ( insertEle.text.length < length ) {
        if (insertEle.nextSibling) {
            if ( !this.consumeElementForInsert(insertEle, insertEle.nextSibling, length) ) {
                if ( insertEle.nextSibling.nodeType === 3 ) {
                    this.splitTextForInsert(insertEle, insertEle.nextSibling, length)
                } else {
                    this.splitElementForInsert(insertEle, insertEle.nextSibling, length, splittableEles)
                }
            }
        } else {
            //no next sibling... need to split parent. this would make parents next sibling for next iteration
            var parent = insertEle.parentNode;
            if (-1 == $.inArray(parent.nodeName.toUpperCase(), splittableEles)) {
                //selection would require splitting non-splittable element
                return { success: false };
            }
            //wrap insertEle with empty clone of parent, then place after parent
            var clone = parent.cloneNode(false);
            while (insertEle.firstChild) {
                clone.appendChild(insertEle.firstChild);
            }
            insertEle.appendChild(clone);
            parent.parentNode.insertBefore(insertEle, parent.nextSibling);
        }
    }
    return { success: true, newElement: insertEle };
};

/**
 * Splits a textnode ('node'), text on the left will be appended to 'container' to make 'container' have
 * as many 'characters' as specified
 *
 * @param container
 * @param node
 * @param characters
 */
splitTextForInsert = function (container, node, characters) {
    var containerLength = $(container).text().length;
    if ( node.nodeValue.length + containerLength > characters ) {
        node.splitText(characters - containerLength);
    }
    container.appendChild(node);
};

/**
 * Puts 'node' into 'container' as long as it can fit given that 'container' can only have so many 'characters'
 *
 * @param container
 * @param node
 * @param characters
 *
 * @returns {boolean} - true if can consume, false if can't. can't consume if element has more text than needed.
 */
consumeElementForInsert = function (container, node, characters) {
    if ( characters - $(container).text().length > $(node).text().length ) {
        container.appendChild(node);
        return true;
    }
    return false;
}

/**
 * Splits 'node' (recursively if necessary) the amount of 'characters' specified, adds left side into 'container'
 *
 * @param container - parent/container of node we are splitting
 * @param node - node we are splitting
 * @param characters - number of characters in markman selection
 * @param splittableEles - array of nodeTypes that can be split, upper case
 * @param originalContainer - original container (before recursive calls)
 * @returns {boolean} - true if we successfully split element or there is nothing to split, false otherwise. false will happen if we try to split
 *  something not in splittableEles or if we run out of characters
 */
splitElementForInsert = function (container, node, characters, splittableEles, originalContainer) {
    originalContainer = originalContainer || container;
    if (-1 == $.inArray(node.nodeName.toUpperCase(), splittableEles)) {
        return false;
    }
    node.normalize();
    var child = node.firstChild;
    if (!child) {
        return true;
    }
    else if (child.nodeType === 3) {
        var $container = $(originalContainer);
        if (characters - $container.text().length - child.nodeValue.length < 1 ) {
            //this portion is enough for the selected range
            var clone = node.cloneNode(false);
            child.splitText(characters - $container.text().length);
            clone.appendChild(child);
            container.appendChild(clone);
            return true;
        } else {
            //throw this text in the container and go on to the next as we still need more
            if (child.nextSibling) {
                var next = child.nextSibling;
                container.appendChild(child);
                return this.splitElementForInsert( container, next, characters, splittableEles, originalContainer );
            } else {
                return true;
            }
        }
    }
    else if (child.nodeType === 1) {
        //child is an element, split that element
        var clone = node.cloneNode(false);
        container.appendChild(clone);
        return this.splitElementForInsert(clone, child, characters, splittableEles, originalContainer);
    }
};

Which I can then call with something like this...

var left = this.selectionInfo.left - paraIdOffset;
var right = this.selectionInfo.right - paraIdOffset;
var parentNode = this.selectionInfo.parentXmlNode;
var someElement = this.xml.createElement(...);
var splittableEles = ['SPAN'];
var createHappened = false;

var startPoint = findStartPoint(parentNode, left);
var insert = splitInsert(startPoint, right-left, splittableEles, someElement );
if (insert.success) {
    createHappened = true;
}
Smern
  • 18,746
  • 21
  • 72
  • 90