1 /* ***** BEGIN LICENSE BLOCK *****
2 * Version: MPL 1.1/GPL 2.0/LGPL 2.1
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/
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
14 * The Original Code is Diavolo.
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.
22 * Daniel Glazman <daniel.glazman@disruptive-innovations.com>, Original author
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.
36 * ***** END LICENSE BLOCK ***** */
38 function DiavoloHighlighter(aSourceString, aGrammarURL, aEditor)
40 this.init(aSourceString, aGrammarURL, aEditor);
43 DiavoloHighlighter.prototype= {
45 kHTML_NAMESPACE: "http://www.w3.org/1999/xhtml",
56 mInsertBeforeNode: null,
57 mInsertionCallback: null,
64 mSelEndContainer: null,
68 getIndexOfNode: function getIndexOfNode(aParent, aNode)
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;
77 return aParent.childNodes.length - 1;
80 installStylesheet: function installStylesheet()
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--)
87 head.removeChild(styleElts[i]);
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);
103 init: function(aSourceString, aGrammarURL, aXULElement)
105 if (!aXULElement || aXULElement.localName != "editor")
109 this.mEditor = aXULElement;
110 this.mDoc = aXULElement.contentDocument;
111 this.mElement = this.mDoc.body;
113 // WARNING the following line is workaround for bug 442686
114 this.mDoc.body.style.whiteSpace = "pre-wrap";
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')";
120 this.mIHTMLEditor = aXULElement.getHTMLEditor(aXULElement.contentWindow);
121 // disable spell checker if any
123 var spc = this.mIHTMLEditor.getInlineSpellChecker(false);
125 spc.enableRealTimeSpell = false;
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...
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();
137 // create a tokenizer for our grammar
138 this.mTokenizer = new DiavoloTokenizer(aGrammarURL);
139 if (!this.mTokenizer)
142 this.installStylesheet();
144 // we start parsing aSourceString from the first char
146 this.mInsertBeforeNode = null;
147 // and since we're not refreshing, no callback on changes
148 this.mInsertionCallback = null;
151 this.checkContext(this, aSourceString,
152 this.getTokenizer().getGrammar().mFirstContextId);
155 // we're done ; let's close the aggregated transaction we started above
156 this.mIHTMLEditor.endTransaction();
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;
169 child = child.lastChild; // this is the last text node in the span
170 this.mIHTMLEditor.selection.collapse(child, child.data.length);
174 var n = this.mElement.childNodes.length;
175 this.mIHTMLEditor.selection.collapse(this.mElement, n -1);
178 // how many lines to we have in our source code ?
181 n = aSourceString.match( /\n/g ).length + 1;
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);
191 postCreate: function postCreate(aElt, aLines)
193 // update the line numbering
194 aElt.wrappedJSObject.update(aLines);
197 getTokenizer: function getTokenizer()
199 return this.mTokenizer;
202 checkContext: function checkContext(aH, aSourceString, contextName)
205 for (var foo = 0; contextName; foo++)
207 var aCtx = aH.getTokenizer().getGrammar().mContexts[contextName];
209 var ignoreSkipUntil = null;
210 var ignoreExpecting = aH.getTokenizer().getGrammar().mFirstContextId;
214 var ignoredToken = true;
216 // get the next token in |aSourceString| at position
218 nextToken = aH.getTokenizer().getNextToken(aSourceString, aH.mLastIndex);
219 // if the result is null, we have hit the boundaries of the string so
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
230 // is our current token ignored ?
231 ignoredToken = (nextToken.name in aCtx.ignores);
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,
242 false, // not partial at this time
243 true)) // token ignored
247 // of course, we loop until the next token is not ignored...
249 while (ignoredToken);
251 // at this point, we have either an error or a token
252 // let's find all the tokens accepted in the current context
254 // clear the next context
256 var nextContextID = null;
257 if (!nextToken.error)
259 for (var i = 0; !nextContextID && i < aCtx.tokens.length; i++)
261 var ctxToken = aCtx.tokens[i];
262 var nextToken = aH.getTokenizer().getNextTokenAs(aSourceString,
267 if (!nextToken.error)
269 // we found a possible token for this context
270 // let's check if we need to look ahead
272 if (ctxToken.lookahead)
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,
280 consume = (lToken && !lToken.error);
281 // do we have a match in the current context ?
285 // yes we do, let's report the token
286 if (aH.reportToken(nextToken.name,
294 if (ctxToken.expecting)
296 nextContextID = ctxToken.expecting;
297 aH.mLastIndex += nextToken.string.length;
307 if (!nextContextID) // we did not consume, we're in an error loop
309 var skipuntils = " ";
310 for (i in aCtx.skipuntils)
311 skipuntils += i + " ";
313 nextToken = aH.getTokenizer().getNextToken(aSourceString, aH.mLastIndex);
314 var skipuntilTokenFound = false;
315 var expecting = contextName;
319 // we reached the end of source string
323 aH.mLastIndex += nextToken.string.length;
324 if (nextToken.name in aCtx.skipuntils)
326 skipuntilTokenFound = true;
328 expecting = aCtx.skipuntils[nextToken.name];
329 nextContextID = expecting;
330 if (aH.reportToken(nextToken.name,
341 if (aH.reportError(nextToken.name,
348 nextToken = aH.getTokenizer().getNextToken(aSourceString, aH.mLastIndex);
351 while (!skipuntilTokenFound);
356 contextName = nextContextID;
360 reportToken: function reportToken(aName, aString, aRole, aExpecting,
361 aCtxName, aPartial, aIgnored)
363 var stopAfterReport = false;
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)
373 if (this.mInsertBeforeNode && this.mInsertBeforeNode.nodeType == Node.ELEMENT_NODE)
375 var n = this.mInsertBeforeNode;
376 // is our new token exactly the one we originally had after the
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"))
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
390 return (!this.mNodesToGo);
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);
401 this.mIHTMLEditor.setAttribute(span, "expecting", aExpecting);
403 this.mIHTMLEditor.setAttribute(span, "partial", "true");
405 this.mIHTMLEditor.setAttribute(span, "ignored", "true");
407 this.mIHTMLEditor.setAttribute(span, "class", aRole);
409 this.mIHTMLEditor.setAttribute(span, "class", aName);
411 var s = doc.createTextNode(aString);
412 this.mIHTMLEditor.insertNode(s, span, 0);
414 // if we're refreshing, call our callback on token insertion
415 if (this.mInsertionCallback)
416 stopAfterReport = this.mInsertionCallback(this, this.mTokenizer);
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++)
427 if (this.reportToken(aName, line, aRole, aExpecting, aCtxName,
431 if (i != lines.length - 1)
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);
439 brNode.setAttribute("expecting", aExpecting);
441 brNode.setAttribute("partial", "true");
443 brNode.setAttribute("ignored", "true");
445 if (this.mInsertionCallback)
446 stopAfterReport |= this.mInsertionCallback(this, this.mTokenizer);
450 return stopAfterReport;
453 reportError: function reportError(aName, aString, aRole, aExpecting,
454 aSkipuntil, aPartial)
456 // just like reportToken() but we insert elements with error="true"
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)
465 var span = doc.createElementNS(this.kHTML_NAMESPACE, "span");
467 span.className = aRole;
469 span.className = aName;
470 span.setAttribute("token", aName);
472 span.setAttribute("expecting", aExpecting);
474 span.setAttribute("partial", "true");
476 span.setAttribute("skipuntil", aSkipuntil)
477 span.setAttribute("error", "true");
479 var s = doc.createTextNode(aString);
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);
488 for (var i = 0; i < lines.length; i++)
493 this.reportError(aName, line, aRole, aExpecting, aSkipuntil, true);
495 if (i != lines.length - 1)
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);
504 return stopAfterReport;
507 preserveSelection: function preserveSelection()
509 // let's preserve the caret's position ; after edit changes,
510 // the selection is always collapsed
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;
520 if (this.mSelEndContainer.nodeType == Node.TEXT_NODE)
522 if (this.mSelEndContainer.parentNode.nodeName.toLowerCase() != "body")
523 this.mSelEndContainer = this.mSelEndContainer.parentNode;
524 this.mGlobalOffset = this.mSelEndOffset;
528 var children = this.mSelEndContainer.childNodes;
529 var l = children.length;
530 for (var i = 0; i < l && i < this.mSelEndOffset; i++)
532 var child = children.item(i);
533 switch (child.nodeType)
536 this.mGlobalOffset += child.data.length;
538 case Node.ELEMENT_NODE:
539 if (child.nodeName.toLowerCase() == "br")
540 this.mGlobalOffset++;
542 this.mGlobalOffset += child.textContent.length;
548 if (this.mSelEndContainer.nodeName.toLowerCase() == "body")
552 var node = this.mSelEndContainer.previousSibling;
555 if (node.nodeName.toLowerCase() == "br")
556 this.mGlobalOffset += 1;
558 this.mGlobalOffset += node.textContent.length;
559 node = node.previousSibling;
563 restoreSelection: function restoreSelection()
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;
570 ((node.nodeName.toLowerCase() == "br") ? 1 : node.textContent.length) < this.mGlobalOffset)
572 if (node.nodeName.toLowerCase() == "br")
573 this.mGlobalOffset--;
575 this.mGlobalOffset -= node.textContent.length;
576 node = node.nextSibling;
580 if (node.nodeName.toLowerCase() == "span")
581 this.mIHTMLEditor.selection.collapse(node.firstChild, this.mGlobalOffset);
582 else if (node.nodeType == Node.TEXT_NODE)
584 this.mIHTMLEditor.selection.collapse(node, this.mGlobalOffset);
586 else // this is a BR element
587 if (this.mGlobalOffset)
588 this.mIHTMLEditor.setCaretAfterElement(node);
590 this.mIHTMLEditor.beginningOfDocument();
595 _textContent: function _textContent(aNode)
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 = "";
603 if (aNode.nodeName.toLowerCase() == "br")
605 if (aNode.nextSibling)
606 sourceString += "\n";
609 sourceString += aNode.textContent;
610 aNode = aNode.nextSibling;