Richtext Editing

Diesen Sommer habe ich den August noch einmal bei Signavio verbringen dürfen. Meine Aufgabe war es einen Richtext Editor zu implementieren. Das besondere dabei war, dass der Editor natürlich nicht nur im Firefox, sondern in allen großen Browsern, also auch Chrome, Opera (, IE6) laufen muss. Dass das nicht leicht werden würde, war mir schon klar aber mit manchen Tücken habe ich wirklich nicht gerechnet. Da mich schon mindestens zwei Leute darauf angesprochen haben, meine Erfahrungen doch mal mit ihnen zu teilen, und um meinen Frust los zu werden, hier also ein Artikel zum Thema Richtext Editing. Zu beachten ist, dass hier einige nicht-standard JavaScript Funktionen genutzt werden. Dies sind Funktionen des PrototypeJS-Frameworks.

Der Anfang…

… schien gar nicht so schlimm! Denn als ich schon überlegte, wie ich die einzelnen Textbausteine am besten mit Tags umschließe, fand ich das hier. Kommandos, die man direkt auf dem document Objekt ausführen kann. Diese Art Kommandos wurden einst von Microsoft in DHTML eingebunden, dann von der Mozilla Gruppe adaptiert und stehen nun in eigentlich allen großen Browsern zur Verfügung. Man braucht nur einen iframe, der den Editor beherbergen soll und in diesem setzt man den sogenannten designMode auf On. Um die ganze Sache auch im IE laufen lassen zu können muss das contentEditable Attribute des body Tags auf true gesetzt werden. Beispiel gefällig?


    // no IE!
    document.observe("dom:loaded", function() {
        document.designMode = "On";
    });

Soweit, so gut. Allerdings wurden nicht alle Befehle von allen Browserherstellen übernommen oder implementiert. Am schlimmsten erweist sich hierbei übrigens Opera. Bemerken tut man dies, wenn man versucht, mit dem selection Objekt zu arbeiten. Opera schmückt sich ja gern damit, mit den neuesten Standards konform zu sein. Wie sie das erreichen ist allerdings eine andere Sache. Im Falle vom Selektionsobjekt wird dies oft durch einfaches stubben von Methoden erreicht. Da diese Methoden dann keine Fehler werfen, sondern einfach nichts tun, erweist sich das debuggen als große Qual. Außerdem sind die Methoden, die tatsächlich Auswirkungen zeigen teils fehlerhaft implementiert. So funktionieren im Opera viele Funktionen nur, wenn man von rechts nach links selektiert, und nicht in der anderen Richtung. Aber zurück zum Thema.

Ein wohl sehr wichtiger Befehl ist heading für Überschriften. Der Befehl erhält als zweiten Parameter lediglich «h[1,2,3,4,5]» um die gewünschte Headline zu erzeugen. Leider zeigt er zur Zeit nur im Firefox seine Wirkung. Opera ignoriert ihn einfach und Chrome zeigt unerwünschtes Verhalten. Kurz nachdem man mit der Implementierung eines Befehls, wie heading begonnen hat, wird einem klar, dass die eigentliche Funktionalität leicht zu erreichen ist, wenn da nicht die Benutzerfreundlichkeit wäre. Ein Benutzer erwartet im Allgemeinen, dass der komplette Paragraph in eine Überschrift gewandelt wird, wenn auch nur ein Bruchteil davon selektiert wurde. Die generelle Idee ist es also, von den Selektionsgrenzen aus nach links und rechts nach begrenzenden Elementen zu suchen. Allgemein würde man hier von einem br tag ausgehen, das einen Zeilenumbruch, und damit einen Abschnitt begrenzt. Leider kommt uns hier wiederum die unterschiedliche Implementierung der Browser in die Quere. Drückt man währen der Eingabe Enter so fügt Firefox br ein, wie man es erwarten würde. Chrome hingegen nutzt div tags, um Abschnitte abzugrenzen und Opera fügt p Elemente ein.

Die richtigen Elemente finden

Mit der Idee im Kopf, dass wir einfach nur nach links und nach rechts suchen müssen, bis wir entweder ein begrenzendes Tag finden oder der nächste Kindknoten undefined ist, fangen wir also an zu programmieren. Aber schon nach kurzer Zeit wird uns klar, dass das nicht so einfach ist. Das Selektionsobjekt gibt nämlich nur Auskunft über den Knoten, in dem es anfängt und aufhört. Da wir Text selektieren wird das meist ein Textknoten sein. Und der kann in b, i und weitere Tags geschachtelt sein. Wir müssen also sehen, dass wir zwei Eltern von den beiden finden, die auf einer Ebene im DOM-Baum liegen und von diesen dann nach links und rechts suchen.

Die Tiefe eines Knotens im DOM-Baum lässt sich rekursiv recht einfach durch folgende Funktion ermitteln.

function getDepth(node, count) {
    if(node.parentNode) {
        return getDepth(node.parentNode, (count || 0) + 1);
    }

    return count || 0;
}

Von hier an denken wir weiter. Wie muss unsere Suche aussehen? Wenn der Startknoten tiefer im Baum liegt, als der Endknoten, müssen wir unsere Suche mit dem Elternknoten von Start und dem gleichen Endknoten fortsetzen. Genau andersrum ist es, wenn der Endknoten tiefer im Baum liegt. Was aber passiert, wenn beide Knoten die gleiche Tiefe haben? Die Annahme, dass dann alles in Ordnung ist und wir einfach mit der Links-Rechts-Suche fortfahren könnten, ist leider falsch. Es könnte folgende Situation aufgetreten sein:

Lorem ipsum Start Text dolor sit End Text amet.

Oder, um die Baumstruktur ein bisschen besser hervorzuheben:

Lorem ipsum
    
        
            Start
        
        Text
    
dolor sit
    
        
            End
        
        Text
    
amet.

Wie man sieht, liegen der Start- und Endtext in einer Ebene, aber die Suche nach links und rechts wäre sinnlos. Hier müssen wir den Algorithmus also auf die Elternknoten, der beiden anwenden.

Die folgende Funktion sucht diejenigen Knoten, die auf einer Ebene im Baum liegen und auch Geschwister sind. Dabei wird ein Objekt zurückgegeben, dass unter den Schlüsseln left und right den linken und rechten Knoten bereithält. Dies ist lediglich eine Hilfe für die spätere Suche nach den Grenzen des Abschnitts. Bitte beachtet auch, dass hier Prototype spezifische Funktionen, wie $A(...) verwendet werden.

function findHighestSiblings(start, end, depthStart, depthEnd) {           

    // first we need to check which node is deeper in the tree
    if(!depthStart) {
        depthStart = getDepth(start);
    }

    if(!depthEnd) {
        depthEnd = getDepth(end);
    }

    if(depthStart > depthEnd && start.parentNode) {
        // start is deeper in the tree
        // -> we check whether the parent of start has the same depth as end
        return findHighestSiblings(start.parentNode, end, undefined, depthEnd);
    } else if (depthStart < depthEnd && end.parentNode) {
        // end is deeper in the tree
        // -> we check whether the parent of end has the same depth as start
        return findHighestSiblings(start, end.parentNode, depthStart);
    } else {
        // both nodes have the same depth but are wrapped in containers
        if(start.parentNode && end.parentNode &&
           start.parentNode !== end.parentNode) {               

            var gpStart = start.parentNode.parentNode;
            var children = $A(gpStart.childNodes);

            if(gpStart) {
                if(children.indexOf(end.parentNode) === -1) {
                    // the wrapping can be multilevel
                    return findHighestSiblings(start.parentNode, end.parentNode, depthStart, depthEnd);
                }                   

                // now we have the same parent and determine which of the elements
                // is the left and which is the right one
                // we need this for the previousSibling-/ nextSibling-search
                if(children.indexOf(start.parentNode) < children.indexOf(end.parentNode)) {
                    return {
                        left: start.parentNode,
                        right: end.parentNode
                    };
                } else {
                    return {
                        left: end.parentNode,
                        right: start.parentNode
                    };
                }
            }
        }

        var sParent = start.parentNode;
        var children = $A(sParent.childNodes);

        if(children.indexOf(start) < children.indexOf(end)) {
            return {
                left: start,
                right: end
            };
        } else {
            return {
                left: end,
                right: start
            };
        }
    }
}

Nachdem die begrenzenden Elemente gefunden wurden, kann diese Selektion mit einem h Tag umschlossen werden. Die Überschrift wurde also erzeugt.

Das Selektionsobjekt

Ist der Dreh- und Angelpunkt beim arbeiten mit Text. Generell läuft man an jeder Stelle in das Objekt, an dem man Text extrahieren, hinzufügen oder bearbeiten möchte. Abfragen kann man das Objekt wie folgt:

function getSelection() {
    // Mozilla
    if (window.getSelection) {
        return window.getSelection();
    }

    // Safari + Chrome
    if (document.getSelection) {
        return document.getSelection();
    } 

    // IE
    if (document.selection) {
        return document.selection.createRange().text;
    }
}

Die Safari/ Chrome-Abzweigung dient allerdings nur für ältere Versionen dieser Browser. Die neusten Versionen folgen alle dem Mozilla Beispiel.

Falsch ist die Annahme, dass die sichtbare Selektion gleich dem Selektionsobjekt ist. Was man sieht sind die sogenannten Ranges des Objektes. Ein Selektionsobjekt kann im allgemeinen Fall beliebig viele Ranges beherrbergen. Dies entspricht dann einer Mehrfachselektion mittels gedrückter STRG-Taste. Allerdings will man sich in den meisten Fällen nur mit der ersten Range beschäftigen. Das Objekt gibt auch Auskunft über die Elemente in denen es beginnt und endet. Wenn man zum Beispiel aus einer Selektion einen Link erstellen möchte, kann man dies wie folgt tun:

function createLink() {
    var selection = getSelection();
    var range = selection.getRangeAt(0);
    var link = undefined;

    link = findLink(range.startContainer, range.endContainer);

    if(link) {
        var href = Element.readAttribute(link, 'href');
        var url = prompt("Edit the following link:", href);                

        if(url && url !== 'http:/'+'/') {
            link.setAttribute('href', url);
        }
    } else {
        var url = prompt("Set link to:", "http://");

        if(url && url !== 'http:/'+'/') {
            document.execCmd('createlink', url);
        }
    }
}

Diese Funktion sucht als erstes in der Selektion nach bereits bestehenden Links. Existiert bereits ein Link, so wird dessen Wert übernommen und zum Editieren angeboten. Wie man sieht ist das eigentliche Erstellen des Links nicht durch mühsehlige, selbstimplementierte Funktionen realisiert, sondern durch das einfache Ausführen des createlink Kommandos auf dem document Objekt.

Unterschiede beim Erstellen von Selektionen

Wenn man per Hand Selektionen erstellen möchte, um dann beispielsweise Kommandos auf ihnen auszuführen, so muss man dabei eine gewisse Vorsicht mit an den Tag bringen.

Das Range Objekt hat von Haus aus die sehr nützlichen Funktionen setStart, setStartBefore, setEnd und setEndAfter. Leider verhalten sich diese Funktionen nicht in allen Browsern gleich. Nehmen wir folgendes Beispiel:

Lorem ipsum dolor sit amet.

Wenn wir nun eine Selektion um das dolor setzen möchten, würde man (oder ich) intuitiv annehmen, dass wir den Start der Selektion vor das b Element setzen und das Ende hinter das Element. Anscheinend waren die Entwickler von Mozilla und Chroma auch meiner Meinung. Allerdings sehen die Jungs bei Opera die Sache etwas anders. Wenn wir im Opera die Selektion vor das b Element setzen, so umschließen wir auch das komplette i Element damit. Gleich verhält es sich für das Ende der Selektion. Was nun also tun? Zum Glück ist das Verhalten der Browser für die setStart und setEnd Methoden gleich. Diese nehmen zwei Parameter. Der erste gibt das Element an, in dem die Selektion liegen soll und einen offset wert.

function selectBetween(l, r) {
    var selection = getSelection();
    var range = selection.getRangeAt(0);

    if(l === r) {
        range.selectNodeContents(l);
    } else {
        range.setStart(l, 0);

        if(this.isTextNode(r)) {
            range.setEnd(r, r.textContent.length);
        } else {
            range.setEnd(r, $A(r.childNodes).length);
        }
    }
}

So, und wem fällt beim Ansehen des Codes die nächste Tücke auf? Nagut, ist nicht so schwer, da ich die Zeilen schon markiert habe. Man hätte annehmen können, beim offset handelt es sich um ein Zeichen-Offset. Das stimmt auch, so lange es sich bei dem gewählten Knoten um einen Textknoten handelt. Ist dies aber nicht mehr der Fall, so beschreibt das Offset die Position des Knotens in der childNodes Liste des Elternelements. Und dann bedeuted es, dass auch das komplette Element mit eingeschlossen wird.

Das soll es für's Erste gewesen sein. Ich habe zwar nicht alle Schwachstellen und Lücken beleuchtet aber auf die gröbsten und fiesesten Stellen aufmerksam gemacht. Hoffentlich ist das hier jedem, der es liest eine Hilfe. An einer Übersetzung ins Englische arbeite ich übrigens noch.

Share and Enjoy:
  • Twitter
  • Facebook
  • del.icio.us
  • Tumblr
  • Digg
  • Sphinn
  • Google Bookmarks
  • Print