OSDN Git Service

import source-tree based svn r84.
[bluegriffon/BlueGriffon.git] / extensions / diavolo / content / highlighter.js
1 /* ***** BEGIN LICENSE BLOCK *****
2  * Version: MPL 1.1/GPL 2.0/LGPL 2.1
3  *
4  * The contents of this file are subject to the Mozilla Public License Version
5  * 1.1 (the "License"); you may not use this file except in compliance with
6  * the License. You may obtain a copy of the License at
7  * http://www.mozilla.org/MPL/
8  *
9  * Software distributed under the License is distributed on an "AS IS" basis,
10  * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
11  * for the specific language governing rights and limitations under the
12  * License.
13  *
14  * The Original Code is Diavolo.
15  *
16  * The Initial Developer of the Original Code is
17  * Disruptive Innovations SARL.
18  * Portions created by the Initial Developer are Copyright (C) 2006-2008
19  * the Initial Developer. All Rights Reserved.
20  *
21  * Contributor(s):
22  *   Daniel Glazman <daniel.glazman@disruptive-innovations.com>, Original author
23  *
24  * Alternatively, the contents of this file may be used under the terms of
25  * either the GNU General Public License Version 2 or later (the "GPL"), or
26  * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
27  * in which case the provisions of the GPL or the LGPL are applicable instead
28  * of those above. If you wish to allow use of your version of this file only
29  * under the terms of either the GPL or the LGPL, and not to allow others to
30  * use your version of this file under the terms of the MPL, indicate your
31  * decision by deleting the provisions above and replace them with the notice
32  * and other provisions required by the GPL or the LGPL. If you do not delete
33  * the provisions above, a recipient may use your version of this file under
34  * the terms of any one of the MPL, the GPL or the LGPL.
35  *
36  * ***** END LICENSE BLOCK ***** */
37
38 function DiavoloHighlighter(aSourceString, aGrammarURL, aEditor)
39 {
40   this.init(aSourceString, aGrammarURL, aEditor);
41 }
42
43 DiavoloHighlighter.prototype= {
44
45   kHTML_NAMESPACE: "http://www.w3.org/1999/xhtml",
46
47   mTokenizer: null,
48   mFirstContext: null,
49   mLastIndex: 0,
50
51   mElement: null,
52   mEditor: null,
53   mDoc: null,
54   mIHTMLEditor: null,
55
56   mInsertBeforeNode: null,
57   mInsertionCallback: null,
58
59   kNODES_TO_GO: 4,
60   mNodesToGo: 0,
61
62   mCharsToConsume: 0,
63
64   mSelEndContainer: null,
65   mSelEndOffset: 0,
66   mGlobalOffset: 0,
67
68   getIndexOfNode: function getIndexOfNode(aParent, aNode)
69   {
70     if (aNode)
71     {
72       // the following 3 lines are an excellent suggestion from Neil Rashbrook
73       var range = aNode.ownerDocument.createRange();
74       range.selectNode(aNode);
75       return range.startOffset;
76     }
77     return aParent.childNodes.length - 1;
78   },
79
80   installStylesheet: function installStylesheet()
81   {
82     // apply the stylesheet for the document
83     var head = this.mDoc.getElementsByTagName("head").item(0);
84     var styleElts = head.getElementsByTagName("style");
85     for (var i = styleElts.length - 1 ; i >= 0; i--)
86     {
87       head.removeChild(styleElts[i]);
88     }
89
90     var style = this.mDoc.createElementNS(this.kHTML_NAMESPACE, "style");
91     style.setAttribute("type", "text/css");
92     var styles = this.mTokenizer.mGrammar.buildStylesheet() +
93                  "span { -moz-binding: url('chrome://diavolo/content/completionPopup.xml#popup'); }" +
94                  "option { -moz-user-select: text !important; -moz-user-input: auto ! important; font-size: smaller; }" +
95                  "div[anonid='ChoicePopup'] select { -moz-user-select: none !important; \
96                            -moz-user-input: auto !important; \
97                            -moz-user-focus: none !important; }";
98     var text = this.mDoc.createTextNode(styles);
99     style.appendChild(text);
100     head.appendChild(style);
101   },
102
103   init: function(aSourceString, aGrammarURL, aXULElement)
104   {
105     if (!aXULElement || aXULElement.localName != "editor")
106       return;
107
108
109     this.mEditor = aXULElement;
110     this.mDoc = aXULElement.contentDocument;
111     this.mElement = this.mDoc.body;
112
113     // WARNING the following line is workaround for bug 442686
114     this.mDoc.body.style.whiteSpace = "pre-wrap";
115
116     this.mDoc.body.style.fontFamily = "-moz-fixed";
117     // apply our binding for line numbering
118     this.mDoc.body.style.MozBinding = "url('chrome://diavolo/content/linenumbering.xml#linenumbering')";
119     
120     this.mIHTMLEditor = aXULElement.getHTMLEditor(aXULElement.contentWindow);
121     // disable spell checker if any
122     try {
123       var spc =  this.mIHTMLEditor.getInlineSpellChecker(false);
124       if (spc)
125         spc.enableRealTimeSpell = false;
126     }
127     catch(e) {}
128
129     // the following line is absolutely mandatory or the editor will react
130     // strangely until it gets the focus... call that a bug in the editor...
131     aXULElement.focus();
132
133     // starting from here, we want all changes we make to the edited doc
134     // to be merged into one single Undo/Redo transaction
135     this.mIHTMLEditor.beginTransaction();
136
137     // create a tokenizer for our grammar
138     this.mTokenizer = new DiavoloTokenizer(aGrammarURL);
139     if (!this.mTokenizer)
140       return null;
141
142     this.installStylesheet();
143     
144     // we start parsing aSourceString from the first char
145     this.mLastIndex = 0;
146     this.mInsertBeforeNode = null;
147     // and since we're not refreshing, no callback on changes
148     this.mInsertionCallback = null;
149
150     // let's dance !
151     this.checkContext(this, aSourceString,
152                       this.getTokenizer().getGrammar().mFirstContextId);
153
154
155     // we're done ; let's close the aggregated transaction we started above 
156     this.mIHTMLEditor.endTransaction();
157
158     // We have to deal with a weird case here... The selection
159     // is originally after the last span of the document and
160     // before the trailing special <br> the editor adds...
161     // That's bad and we want the caret to be placed at the end
162     // of the text node child of the last span if any, and right
163     // before the last <br> is there's no span in the document.
164     var child = this.mElement.lastChild;
165     while (child && child.nodeName.toLowerCase() != "span")
166       child = child.previousSibling;
167     if (child)
168     {
169       child = child.lastChild; // this is the last text node in the span
170       this.mIHTMLEditor.selection.collapse(child, child.data.length);
171     }
172     else
173     {
174       var n = this.mElement.childNodes.length;
175       this.mIHTMLEditor.selection.collapse(this.mElement, n -1);
176     }
177
178     // how many lines to we have in our source code ?
179     var n = 1;
180     try {
181       n = aSourceString.match( /\n/g ).length + 1;
182     }
183     catch(e) {}
184
185     // let's update line numbering.
186     // we need to let a few cycles pass because of the line numbering XBL
187     // otherwise it's not applied yet :-(
188     setTimeout(this.postCreate, 100, this.mIHTMLEditor.rootElement, n);
189   },
190
191   postCreate: function postCreate(aElt, aLines)
192   {
193     // update the line numbering
194     aElt.wrappedJSObject.update(aLines);
195   },
196
197   getTokenizer: function getTokenizer()
198   {
199     return this.mTokenizer;
200   },
201
202   checkContext: function checkContext(aH, aSourceString, contextName)
203   {
204     // sanity check
205     for (var foo = 0; contextName; foo++)
206     {
207       var aCtx = aH.getTokenizer().getGrammar().mContexts[contextName];
208
209       var ignoreSkipUntil = null;
210       var ignoreExpecting = aH.getTokenizer().getGrammar().mFirstContextId;
211
212       var nextToken;
213
214       var ignoredToken = true;
215       do {
216         // get the next token in |aSourceString| at position
217         // |this.mLastIndex|
218         nextToken = aH.getTokenizer().getNextToken(aSourceString, aH.mLastIndex);
219         // if the result is null, we have hit the boundaries of the string so
220         // let's go shopping
221         if (!nextToken)
222           return;
223
224         // we do not have a real token, the tokenizer only returned the next
225         // char on the stack and said it can't understand it ; since an error
226         // cannot be an ignored token, we break here
227         if (nextToken.error)
228           break;
229
230         // is our current token ignored ?
231         ignoredToken = (nextToken.name in aCtx.ignores);
232         if (ignoredToken)
233         {
234           // yes, it's ignored ; first let's update the stack
235           aH.mLastIndex += nextToken.string.length;
236           // and report an ignored token
237           if (aH.reportToken(nextToken.name,
238                                nextToken.string,
239                                null,
240                                null,
241                                contextName,
242                                false, // not partial at this time
243                                true)) // token ignored
244             return ;
245
246         }
247         // of course, we loop until the next token is not ignored...
248       }
249       while (ignoredToken);
250
251       // at this point, we have either an error or a token
252       // let's find all the tokens accepted in the current context
253
254       // clear the next context
255
256       var nextContextID = null;
257       if (!nextToken.error)
258       {
259         for (var i = 0; !nextContextID && i < aCtx.tokens.length; i++)
260         {
261           var ctxToken = aCtx.tokens[i];
262           var nextToken = aH.getTokenizer().getNextTokenAs(aSourceString,
263                                                            aH.mLastIndex,
264                                                            ctxToken.type);
265           if (!nextToken)
266             return;
267           if (!nextToken.error)
268           {
269             // we found a possible token for this context
270             // let's check if we need to look ahead
271             var consume = true;
272             if (ctxToken.lookahead)
273             {
274               // yep, look ahead needed, let's get another token without
275               // updating this.mLastIndex
276               var lookaheadTokenName = ctxToken.lookahead;
277               var lToken = aH.getTokenizer().getNextTokenAs(aSourceString,
278                                                             aH.mLastIndex + nextToken.string.length,
279                                                             lookaheadTokenName); 
280               consume = (lToken && !lToken.error);
281               // do we have a match in the current context ?
282             }
283             if (consume)
284             {
285               // yes we do, let's report the token
286               if (aH.reportToken(nextToken.name,
287                                    nextToken.string,
288                                    ctxToken.role,
289                                    ctxToken.expecting,
290                                    contextName,
291                                    false,
292                                    false))
293                 return;
294               if (ctxToken.expecting)
295               {
296                 nextContextID = ctxToken.expecting;
297                 aH.mLastIndex += nextToken.string.length;
298               }
299               else
300                 return;
301             }
302
303           }
304         }
305       }
306
307       if (!nextContextID) // we did not consume, we're in an error loop
308       {
309         var skipuntils = " ";
310         for (i in aCtx.skipuntils)
311           skipuntils += i + " ";
312         try {
313           nextToken = aH.getTokenizer().getNextToken(aSourceString, aH.mLastIndex);
314           var skipuntilTokenFound = false;
315           var expecting = contextName;
316           do {
317             if (!nextToken)
318             {
319               // we reached the end of source string
320               return;
321             }
322
323             aH.mLastIndex += nextToken.string.length;
324             if (nextToken.name in aCtx.skipuntils)
325             {
326               skipuntilTokenFound = true;
327
328               expecting = aCtx.skipuntils[nextToken.name];
329               nextContextID = expecting;
330               if (aH.reportToken(nextToken.name,
331                                    nextToken.string,
332                                    "SKIPUNTIL",
333                                    expecting,
334                                    contextName,
335                                    false,
336                                    false))
337                 return;
338             }
339             else
340             {
341               if (aH.reportError(nextToken.name,
342                                    nextToken.string,
343                                    null,
344                                    expecting,
345                                    skipuntils,
346                                    false))
347                 return;
348               nextToken = aH.getTokenizer().getNextToken(aSourceString, aH.mLastIndex);
349             }
350           }
351           while (!skipuntilTokenFound);
352         }
353         catch(e) {}
354       }
355
356       contextName = nextContextID;
357     }
358   },
359
360   reportToken: function reportToken(aName, aString, aRole, aExpecting,
361                                     aCtxName, aPartial, aIgnored)
362   {
363     var stopAfterReport = false;
364     
365     if (!this.mElement || !aString)
366       return stopAfterReport;
367     // do we have more than one line in our token ?
368     var lines = aString.split( /\r\n|\n/g );
369     var doc = this.mElement.ownerDocument;
370     if (lines.length == 1)
371     {
372       // one line only
373       if (this.mInsertBeforeNode && this.mInsertBeforeNode.nodeType == Node.ELEMENT_NODE)
374       {
375         var n = this.mInsertBeforeNode;
376         // is our new token exactly the one we originally had after the
377         // recent changes?
378         if (aString == n.textContent &&
379             aName == n.getAttribute("token") &&
380             aCtxName == n.getAttribute("context") &&
381             aExpecting == n.getAttribute("expecting") &&
382             aPartial == n.hasAttribute("partial") &&
383             !aIgnored && !n.hasAttribute("ignored") &&
384             (aRole ? aRole : aName) == n.getAttribute("class"))
385         {
386           // yes, and it's not ignored ; do nothing and early way out
387           this.mInsertBeforeNode = this.mInsertBeforeNode.nextSibling;
388           // one "clean" token less to go
389           this.mNodesToGo--;
390           return (!this.mNodesToGo);
391         }
392       }
393
394       // create a new span for our token
395       var offset = this.getIndexOfNode(this.mElement, this.mInsertBeforeNode);
396       // and insert it before our insertion point
397       var span = this.mIHTMLEditor.createNode("span", this.mElement, offset);
398       this.mIHTMLEditor.setAttribute(span, "token", aName);
399       this.mIHTMLEditor.setAttribute(span, "context", aCtxName);
400       if (aExpecting)
401         this.mIHTMLEditor.setAttribute(span, "expecting", aExpecting);
402       if (aPartial)
403         this.mIHTMLEditor.setAttribute(span, "partial", "true");
404       if (aIgnored)
405         this.mIHTMLEditor.setAttribute(span, "ignored", "true");
406       if (aRole)
407         this.mIHTMLEditor.setAttribute(span, "class", aRole);
408       else
409         this.mIHTMLEditor.setAttribute(span, "class", aName);
410
411       var s = doc.createTextNode(aString);
412       this.mIHTMLEditor.insertNode(s, span, 0);
413
414       // if we're refreshing, call our callback on token insertion
415       if (this.mInsertionCallback)
416         stopAfterReport = this.mInsertionCallback(this, this.mTokenizer);
417     }
418     else
419     {
420       // it's a multiline token, we're going to split it into multiple
421       // spans and BRs carrying partial="true"
422       for (var i = 0; i < lines.length; i++)
423       {
424         var line = lines[i];
425         if (line)
426         {
427           if (this.reportToken(aName, line, aRole, aExpecting, aCtxName,
428                                (i != 0), aIgnored))
429             return true;
430         }
431         if (i != lines.length - 1)
432         {
433           var offset = this.getIndexOfNode(this.mElement, this.mInsertBeforeNode);
434           var brNode = doc.createElementNS(this.kHTML_NAMESPACE, "br");
435           this.mIHTMLEditor.insertNode(brNode, this.mElement, offset);
436           brNode.setAttribute("token", aName);
437           brNode.setAttribute("context", aCtxName);
438           if (aExpecting)
439             brNode.setAttribute("expecting", aExpecting);
440           if (aPartial)
441             brNode.setAttribute("partial", "true");
442           if (aIgnored)
443             brNode.setAttribute("ignored", "true");
444
445           if (this.mInsertionCallback)
446             stopAfterReport |= this.mInsertionCallback(this, this.mTokenizer);
447         }
448       }
449     }
450     return stopAfterReport;
451   },
452
453   reportError: function reportError(aName, aString, aRole, aExpecting,
454                                     aSkipuntil, aPartial)
455   {
456     // just like reportToken() but we insert elements with error="true"
457
458     var stopAfterReport = false;
459     if (!this.mElement || !aString)
460       return stopAfterReport;
461     var lines = aString.split( /\r\n|\n/g );
462     var doc = this.mElement.ownerDocument;
463     if (lines.length == 1)
464     {
465       var span = doc.createElementNS(this.kHTML_NAMESPACE, "span");
466       if (aRole)
467         span.className = aRole;
468       else
469         span.className = aName;
470       span.setAttribute("token", aName);
471       if (aExpecting)
472         span.setAttribute("expecting", aExpecting);
473       if (aPartial)
474         span.setAttribute("partial", "true");
475       if(aSkipuntil)
476         span.setAttribute("skipuntil", aSkipuntil)
477       span.setAttribute("error", "true");
478       
479       var s = doc.createTextNode(aString);
480       span.appendChild(s);
481       var offset = this.getIndexOfNode(this.mElement, this.mInsertBeforeNode);
482       this.mIHTMLEditor.insertNode(span, this.mElement, offset);
483       if (this.mInsertionCallback)
484         stopAfterReport = this.mInsertionCallback(this, this.mTokenizer);
485     }
486     else
487     {
488       for (var i = 0; i < lines.length; i++)
489       {
490         var line = lines[i];
491         if (line)
492         {
493           this.reportError(aName, line, aRole, aExpecting, aSkipuntil, true);
494         }
495         if (i != lines.length - 1)
496         {
497           var offset = this.getIndexOfNode(this.mElement, this.mInsertBeforeNode);
498           this.mIHTMLEditor.insertNode(doc.createElementNS(this.kHTML_NAMESPACE, "br"), this.mElement, offset);
499           if (this.mInsertionCallback)
500             stopAfterReport |= this.mInsertionCallback(this, this.mTokenizer);
501         }
502       }
503     }
504     return stopAfterReport;
505   },
506
507   preserveSelection: function preserveSelection()
508   {
509     // let's preserve the caret's position ; after edit changes,
510     // the selection is always collapsed
511
512     // we're going to preserve it counting the chars from the
513     // beginning of the document up to the caret, just like in
514     // a plaintext editor ; BRs count for "\n" so 1 char.
515     var range = this.mIHTMLEditor.selection.getRangeAt(0);
516     this.mSelEndContainer   = range.endContainer;
517     this.mSelEndOffset      = range.endOffset;
518     this.mGlobalOffset = 0; 
519
520     if (this.mSelEndContainer.nodeType == Node.TEXT_NODE)
521     {
522       if (this.mSelEndContainer.parentNode.nodeName.toLowerCase() != "body")
523         this.mSelEndContainer = this.mSelEndContainer.parentNode;
524       this.mGlobalOffset = this.mSelEndOffset;
525     }
526     else
527     {
528       var children = this.mSelEndContainer.childNodes;
529       var l = children.length;
530       for (var i = 0; i < l && i < this.mSelEndOffset; i++)
531       {
532         var child = children.item(i);
533         switch (child.nodeType)
534         {
535           case Node.TEXT_NODE:
536             this.mGlobalOffset += child.data.length;
537             break;
538           case Node.ELEMENT_NODE:
539             if (child.nodeName.toLowerCase() == "br")
540               this.mGlobalOffset++;
541             else
542               this.mGlobalOffset += child.textContent.length;
543             break;
544           default:
545             break;
546         }
547       }
548       if (this.mSelEndContainer.nodeName.toLowerCase() == "body")
549         return;
550     }
551
552     var node = this.mSelEndContainer.previousSibling;
553     while (node)
554     {
555       if (node.nodeName.toLowerCase() == "br")
556         this.mGlobalOffset += 1;
557       else
558         this.mGlobalOffset += node.textContent.length;
559       node = node.previousSibling;
560     }
561   },
562
563   restoreSelection: function restoreSelection()
564   {
565     // let's restore the caret at our previous position using the
566     // char offset we stored in restoreSelection()
567     var node = this.mElement.firstChild;
568     var offset = 0;
569     while (node &&
570            ((node.nodeName.toLowerCase() == "br") ? 1 : node.textContent.length) < this.mGlobalOffset)
571     {
572       if (node.nodeName.toLowerCase() == "br")
573         this.mGlobalOffset--;
574       else
575         this.mGlobalOffset -= node.textContent.length;
576       node = node.nextSibling;
577     }
578     if (node)
579     {
580       if (node.nodeName.toLowerCase() == "span") 
581         this.mIHTMLEditor.selection.collapse(node.firstChild, this.mGlobalOffset);
582       else if (node.nodeType == Node.TEXT_NODE) 
583       {
584         this.mIHTMLEditor.selection.collapse(node, this.mGlobalOffset);
585       }
586       else // this is a BR element
587         if (this.mGlobalOffset)
588           this.mIHTMLEditor.setCaretAfterElement(node);
589         else
590           this.mIHTMLEditor.beginningOfDocument();
591     }
592   },
593
594
595   _textContent: function _textContent(aNode)
596   {
597     // get source string starting at aNode
598     // we can't use |body.textContent| because it does not give us
599     // a \n for BR elements... Unfortunately...
600     var sourceString = "";
601     while (aNode)
602     {
603       if (aNode.nodeName.toLowerCase() == "br")
604       {
605         if (aNode.nextSibling)
606           sourceString += "\n";
607       }
608       else
609         sourceString += aNode.textContent; 
610       aNode = aNode.nextSibling;
611     }
612     return sourceString;
613   }
614
615 };