sonic: Position des texcursors in einem Element ermitteln/setzen

Hi!

Ich bastle gerade an einem kleinen Quelltext-Editor, der in einem pre mit contenteditable=true sitzt. Der Editor beherrscht syntax highlighting, d.h. innerhalb des pre-Containers finden sich Unmengen von span-Tags nach folgendem Schema:

  
<pre id="editor">  
<span class="php">  
<span class="keyword">var</span>  
<span class="variable">$test</span>  
<span class="operator">=</span>  
</span>  
<span class="string_interpolated">"Text</span>  
<span class="escaped">\n</span>  
<span class="string_interpolated">"</span>  
</span>  
</pre>  

und so weiter. Das Highlighting klappt soweit ganz gut, zwingt mich aber, die spans oft zu löschen und neu zu rendern (z.B. wenn ein Anführungszeichen gelöscht). Das Problem ist jetzt, dass jedes mal, wenn eine span ersetzt werden muss, der Textcursor zum letzten nicht ersetzten Element zurückspringt.

Ich kann zwar mir window.getSelection rausfinden, wo sich der Cursor vor dem Ersetzen der spans befindet, aber leider wird die Position relativ zum aktiven span und nicht zum Wurzelelement des editierbaren Bereichs angegeben. Daher die Frage: Gibt es a) eine Möglichkeit, den Browsern (IE ist erstmal zweitranging) beizubringen, dass sie den anchorOffset relativ zu einem von mir bestimmten Element berechnen, und b) eine Möglichkeit, die Textcursor-Position auch relativ zu einem beliebigen Elternelement zu setzen?

  1. Ich hab wohl einen Link für dich. Ob dir das hilft kann ich allerdings grade nicht sagen (Hoffe ich jedoch).
    http://www.quirksmode.org/dom/range_intro.html

    Da steht zumindest einiges über Text-Selection und wie die verschiedenen Objekte in den verschieden Browsern angesprochen werden.

    Unteranderem ist dies hier zu finden:

      
    var rangeObject = getRangeObject(userSelection);  
      
    function getRangeObject(selectionObject) {  
    	if (selectionObject.getRangeAt)  
    		return selectionObject.getRangeAt(0);  
    	else { // Safari!  
    		var range = document.createRange();  
    		range.setStart(selectionObject.anchorNode,selectionObject.anchorOffset);  
    		range.setEnd(selectionObject.focusNode,selectionObject.focusOffset);  
    		return range;  
    	}  
    }  
    
    

    Hoffe das hilft dir.

    Gruß

    Frank

    1. var rangeObject = getRangeObject(userSelection);

      function getRangeObject(selectionObject) {
      if (selectionObject.getRangeAt)
      return selectionObject.getRangeAt(0);
      else { // Safari!
      var range = document.createRange();
      range.setStart(selectionObject.anchorNode,selectionObject.anchorOffset);
      range.setEnd(selectionObject.focusNode,selectionObject.focusOffset);
      return range;
      }
      }

      
      >   
      > Hoffe das hilft dir.  
        
      Nicht wirklich, glaube ich. Das Problem ist, dass selectionObject.anchorOffset sich immer auf den innersten Node bezieht, d.h. wenn ich folgenden Code habe:  
        
      ~~~html
        
      <pre class="editor">echo $test;</pre>  
      
      

      und mein Cursor direkt hinter dem Dollarzeichen steht, bekomme ich anchorOffset 6, was die Information ist, die ich haben will, wenn allerdings das Syntax Highlighting aktiv ist, sieht der obere Code in etwa wie folgt aus:

        
      <pre class="editor"><span class='keyword'>echo</span> <span class='variable'>$test</span>;</pre>  
        
      anchorOffset gibt mir dann 1 aus, wenn mein Cursor hinter dem Dollarzeichen steht, da als anchorNode das span angesehen wird und nicht wie zuvor das umschließende pre, d.h. um die Position vom Anfang des Editor aus zu bestimmen müsste ich mich durch die ganzen previousSiblings durchhangeln und deren Länge zusammenzählen, aber ich fürchte, dass das von der Performance her nicht realistisch ist (v.a. da schnell hunderte von spans zusammenkommen können)  
      
      
      1. Update: Habe jetzt herausgefunden, wie ich die Position relativ zum Anfang des Editors bestimmen kann:

          
        var sel = window.getSelection();  
        var range = document.createRange();  
          
        range.setStart(editor_root, 0);  
        range.setEnd(sel.anchorNode,sel.anchorOffset);  
          
        var caret_pos = range.toString().length;  
        
        

        Ist zwar finde ich etwas hackish, klappt aber soweit. Nur leider stehe ich beim Setzen des Cursors wieder vor dem selben Problem. Nach dem Highlighting springt der Cursor zurück auf Position null, wenn ich ihn dann wieder an die korrekte Position verschieben will, wirft die Funktion

          
        sel.extend(editor_root, caret_pos);  
        
        

        Fehler mit "Index or size is negative or greater than the allowed amount", weil extend (genauso wie setEnd und setStart) den offset immer vom innersten Node aus bestimmt. Welcher Node der innerste ist, weiß ich zu dem Zeitpunkt aber noch nicht. Argh.

        1. Ich hab heute Nacht auch noch zwei Stunden nach ner Lösung gesucht, aber leider gar nicts gefunden ausser Parsen. Was mir dann auch irgendwann aufgefallen ist, ist das was du grade sagst. Wenn du den Inhalt änderst bist du wieder in einer fällig neuen Herarchie.

          Wie wird bei dir das Ereignis onchange (oder so) ausgelöst? Aufgeben will ich noch grade nicht.

          Gruß

          Frank

          1. Ich hab heute Nacht auch noch zwei Stunden nach ner Lösung gesucht, aber leider gar nicts gefunden ausser Parsen. Was mir dann auch irgendwann aufgefallen ist, ist das was du grade sagst. Wenn du den Inhalt änderst bist du wieder in einer fällig neuen Herarchie.

            In vielen Fällen könnte man den Highlighter vermutlich so optimieren, dass er nur Klassen bzw. innerHTML von spans austauscht oder nur Elemente rechts vom Cursor ersetzt, aber für alle denkbaren Edit-Operationen wird das nicht hinhauen. Das Wurzelelement des Editors bleibt allerdings immer gleich, d.h. wenn man der Range irgendwie beibringen könnte, sich immer auf das Wurzelelement zu beziehen könnte es vielleicht klappen.

            Wie wird bei dir das Ereignis onchange (oder so) ausgelöst?

            Nicht dass ich wüsste. Vielleicht kann man es irgendwie triggern, aber ich hätte im Moment gar keine Idee, was ich damit anfangen könnte. Das Highlighting wird momentan auf onkeyup/onmouseup ausgelöst, was vermutlich ein wenig exzessiv ist, aber für eine dev-Version schon ok.

            Aufgeben will ich noch grade nicht.

            Naja, ich bin mittlerweile skeptisch. Ich habe mal eine Quick and Dirty-Iterator-Lösung gebaut, die die korrekten spans und offsets aus den childNodes raussuchen kann, wenn ich mit den Werten dann aber folgendes mache:

              
            var new_range = document.createRange();  
            new_range.setStart(anchor, anchor_offset);  
            new_range.setEnd(anchor, anchor_offset);  
            sel.addRange(new_range);  
            
            

            bekomme ich schon in der zweiten Zeile bei etwa der Hälfte aller Cursor-Positionen den Fehler "Index or size is negative or greater than the allowed amount". Lt. Firebug wird das selection-Objekt außerdem ab und zu aus unerfindlichen Gründen auf null gesetzt, d.h. insgesamt ist das Verhalten ziemlich erratisch. Das wird z.T. bestimmt an meinen eigenen Bugs liegen, aber ich frage mich trotzdem, ob diese Features browserseitig überhaupt für dieses Einsatzgebiet verwendbar sind. Quellcode-Editieren und WYSIWYG-Editieren sind halt konzeptionell doch relativ verschieden.

            1. Also der das Auslesen scheint bei dieser Variante zu funktionieren:

                
              <html>  
              <body>  
              <script language="Javascript">  
              var userSelection;  
              if (window.getSelection) {  
              	userSelection = window.getSelection();  
              }  
              else if (document.selection) { // should come last; Opera!  
              	userSelection = document.selection.createRange();  
              }  
              function getRangeObject(selectionObject) {  
                      if (selectionObject.getRangeAt)  
                              return selectionObject.getRangeAt(0);  
                      else { // Safari!  
                              var range = document.createRange();  
                              range.setStart(selectionObject.anchorNode,selectionObject.anchorOffset);  
                              range.setEnd(selectionObject.focusNode,selectionObject.focusOffset);  
                              return range;  
                      }  
              }  
                
              function getSel()  
              {  
              	var editor = document.getElementById('editor');  
              	var rangeObject = getRangeObject(userSelection);  
              	var el = userSelection.anchorNode.parentNode;	// anchor should be #text, parent should be SPAN or PRE  
              	var count = 0;  
              	var el1;  
              	  
              	if(el.nodeName == 'PRE')	{  
              		count = editor.textContent.length;  
              		console.log('This happens when cursor is at absolute last postion');  
              	}else if (el.nodeName == 'SPAN'){  
              		count = userSelection.anchorOffset;  
              		el1 = editor.firstElementChild;  
              		while (el1 != el){  
              			count += el1.textContent.length;  
              			el1 = el1.nextElementSibling;  
              		}	  
              	}else{  
              		count = 0;  
              		console.log('This happens when the cursor is at absolute first position');  
              	}  
              		  
              	console.log('sel start is: ' + count);  
              	return count;  
              }  
              function setSel(count)  
              {  
              	  
              }  
              </script>  
              </body>  
              <pre id="editor" contenteditable="true" onkeyup="change();"><span class="command">echo </span><span class="variable">$test</span></pre>  
                
              <input type="text" id="setSel"/><input type="button" value="setzen" onclick="set(document.getElementById('setSel').value);" />  
              </html>  
              
              

              Jetzt versuch ich noch das setzen des Cursor hinzubekommen. Da hab ich das Prinzip allerdings noch nicht verstanden.
              Einzigstes "Aber" ist, jedes druckbare Zeichen muss sich in einem SPAN befinden! Auch Leerzeichen und Zeilenumbruch (wenn nicht </br>).

              Gruß

              Frank

              1. Hier mal eine Variante dessen, was ich gebaut habe. Das Auslesen klappt, beim Setzen gibt es immer den Fehler (Zum Ausprobieren kannst Du einfach irgendwo im Test-String eine Eingabe machen, dann wird die Caret-Position angezeigt. Wenn Du anschließend auf den Button klickst, wird das innerHTML ersetzt und der Cursor sollte danach wieder auf die richtige Position geschoben werden, was aber leider nicht passiert).

                  
                <div id="container"></div>  
                <div id="pos"></div>  
                <input type="button" onclick="javascript:replace_content();" value="test"/>  
                  
                <script type="text/javascript">  
                var ed = document.createElement('pre');  
                  
                ed.innerHTML = '<span class="other"><span class="preproc">&lt;?php</span></span><span class="php"><span class="function"> echo </span><span class="var">$test</span><span class="interpunction">;</span><span class="preproc">?></span></span>';  
                  
                ed.contentEditable = true;  
                  
                document.getElementById('container').appendChild(ed);  
                  
                ed.onkeyup = function(e) {  
                    var sel = window.getSelection();  
                    var range = document.createRange();  
                  
                    range.setStart(ed, 0);  
                    range.setEnd(sel.anchorNode, sel.anchorOffset);  
                  
                    var caret_pos = range.toString().length;  
                    document.getElementById('pos').innerHTML = caret_pos;  
                }  
                  
                function replace_content() {  
                    ed.innerHTML = '<span class="other"><span class="preproc">&lt;?php</span></span><span class="php"><span class="function"> echo </span><span class="var">$test</span><span class="interpunction">;</span></span>';  
                  
                    var caret_pos = parseInt(document.getElementById('pos').innerHTML);  
                  
                    var ptr = 0;  
                  
                    outer: for (var i = 0; i < ed.childNodes.length; i++) {  
                      for (var j = 0; j < ed.childNodes[i].childNodes.length; j++) {  
                            var tmp = ed.childNodes[i].childNodes[j];  
                            if (tmp.textContent.length + ptr < caret_pos) {  
                                ptr += tmp.textContent.length;  
                                continue;  
                            }  
                            else {  
                                var new_anchor = tmp;  
                                var anchor_offset = caret_pos - ptr;  
                                break outer;  
                            }  
                        }  
                    }  
                  
                    if (new_anchor) {  
                        console.log('Anchor Content: "' + new_anchor.innerHTML + '"');  
                        console.log('Anchor Offset' + anchor_offset);  
                        var sel = window.getSelection();  
                        var new_range = document.createRange();  
                        new_range.setStart(new_anchor, anchor_offset);  
                        new_range.setEnd(new_anchor, anchor_offset);  
                        sel.addRange(new_range);  
                    }  
                  
                }  
                  
                </script>