views:

638

answers:

3

I am displaying some simple styled text as html in a UIWebView on iPhone. It is basically a series of paragraphs with the occasional strong or emphasized phrase. At runtime I need to apply styles to ranges of text.

There are a few similar scenarios, one of which is highlighting search results. If the user has searched for "something" I would like to change the background color behind occurrences of the word, then later restore the original background.

Is it possible to apply styles to ranges of text using javascript? A key part of this is also being able to unset the styles.

There seem to be two likely paths to follow. One would be modifying some html in Objective-C and passing it through javascript as the new innerHTML of some container. The other would be to use javascript to directly manipulate DOM nodes.

I could manipulate html, but that sounds tedious in Objective-C so I would rather manipulate the DOM if that is a reasonable approach. I am not that familiar with javascript and DOM so I do not know if it is a reasonable approach.

I wrote some routines to translate between text ranges and node ranges with offsets. So if I start with text range 100-200 and that starts in one paragraph and ends in a third, I can get the text nodes and the offsets within the nodes that represent the given text range. I just need a way to split a text node at an offset in the text. Currently I just apply styles to the paragraphs containing the text range.

A few notes:

  • straight javascript please, no external frameworks like jquery.
  • the changes never need to be written to disk.
  • the changes should be undoable or at least removable.
  • the styles to apply already exist in a css file.
  • it needs to work in iPhone 3.0 and forward.
  • all the source files are shipped with the app.
  • please be verbose.

Thanks for any suggestions.

+8  A: 

I think you're asking a lot to get a complete solution for this, but it seemed interesting so I've implemented it. The following works in recent WebKit browsers, including Safari on iPhone running OS 3.0. It uses the non-standard but convenient intersectsNode method of Range, which exists in WebKit but was removed from Firefox in 3.0, so it doesn't work in recent versions of Firefox but could be made to do so trivially.

The following will surround each selected text node with a <span> element with a class of "someclass" and also a unique class to allow easy undoing. applyClassToSelection returns this unique class; pass this class into removeSpansWithClass to remove the spans.

UPDATE: Fixed problem when selection is entirely contained within a single text node

UPDATE 2: Now tested and works in iPhone running OS 3.0.

UPDATE 3: Added rangeIntersectsNode function to add support for Firefox 3.0 and later. This code should now work in Firefox 1.0+, Safari 3.1+, Google Chrome, Opera 9.6+ and possibly others (untested so far). It does not work at all in Internet Explorer and will give errors in that browser. I plan to work on an IE version soon.

<script type="text/javascript">
    var nextId = 0;

    var rangeIntersectsNode = (typeof window.Range != "undefined"
            && Range.prototype.intersectsNode) ?

        function(range, node) {
            return range.intersectsNode(node);
        } :

        function(range, node) {
            var nodeRange = node.ownerDocument.createRange();
            try {
                nodeRange.selectNode(node);
            } catch (e) {
                nodeRange.selectNodeContents(node);
            }

            return range.compareBoundaryPoints(Range.END_TO_START, nodeRange) == -1 &&
                range.compareBoundaryPoints(Range.START_TO_END, nodeRange) == 1;
        };

    function applyClassToSelection(cssClass) {
        var uniqueCssClass = "selection_" + (++nextId);
        var sel = window.getSelection();
        if (sel.rangeCount < 1) {
            return;
        }
        var range = sel.getRangeAt(0);
        var startNode = range.startContainer, endNode = range.endContainer;

        // Split the start and end container text nodes, if necessary
        if (endNode.nodeType == 3) {
            endNode.splitText(range.endOffset);
            range.setEnd(endNode, endNode.length);
        }

        if (startNode.nodeType == 3) {
            startNode = startNode.splitText(range.startOffset);
            range.setStart(startNode, 0);
        }

        // Create an array of all the text nodes in the selection
        // using a TreeWalker
        var containerElement = range.commonAncestorContainer;
        if (containerElement.nodeType != 1) {
            containerElement = containerElement.parentNode;
        }

        var treeWalker = document.createTreeWalker(
            containerElement,
            NodeFilter.SHOW_TEXT,
            // Note that Range.intersectsNode is non-standard but
            // implemented in WebKit
            function(node) {
                return rangeIntersectsNode(range, node) ?
                    NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
            },
            false
        );

        var selectedTextNodes = [];
        while (treeWalker.nextNode()) {
            selectedTextNodes.push(treeWalker.currentNode);
        }

        var textNode, span;

        // Place each text node within range inside a <span>
        // element with the desired class
        for (var i = 0, len = selectedTextNodes.length; i < len; ++i) {
            textNode = selectedTextNodes[i];
            span = document.createElement("span");
            span.className = cssClass + " " + uniqueCssClass;
            textNode.parentNode.insertBefore(span, textNode);
            span.appendChild(textNode);
        }

        return uniqueCssClass;
    }

    function removeSpansWithClass(cssClass) {
        var spans = document.body.getElementsByClassName(cssClass),
            span, parentNode;

        // Convert spans to an array to prevent live updating of
        // the list as we remove the spans
        spans = Array.prototype.slice.call(spans, 0);

        for (var i = 0, len = spans.length; i < len; ++i) {
            span = spans[i];
            parentNode = span.parentNode;
            parentNode.insertBefore(span.firstChild, span);
            parentNode.removeChild(span);

            // Glue any adjacent text nodes back together
            parentNode.normalize();
        }
    }

    var c;
</script>

<input type="button" onclick="c = applyClassToSelection('someclass')"
    value="Add class">
<input type="button" onclick="removeSpansWithClass(c)"
    value="Remove class">
Tim Down
Thank you. This is more than I hoped for.
drawnonward
Perhaps put this on github, so we can follow at least.
Mark
I'm working on a cross-browser range/selection library that will include an improved version of this. Not much progress yet, but I've created a Google Code project: http://code.google.com/p/rangy/
Tim Down
@Mark, @user306253, @Glasswing, @jozsef: I've updated my library with a module that does this in all browsers, including IE: http://code.google.com/p/rangy/
Tim Down
A: 

Tim, this is the second snippet you have posted that has solved a huge problem for me. Thank you sooo much for taking the time to help us.

Glasswing
A: 

great stuff man, waiting for IE fix too.Thank you for your help,man

jozsef