<?xml version="1.0"?>
<!--
    ***** BEGIN LICENSE BLOCK *****
    
    Copyright © 2009 Center for History and New Media
                     George Mason University, Fairfax, Virginia, USA
                     http://zotero.org
    
    This file is part of Zotero.
    
    Zotero is free software: you can redistribute it and/or modify
    it under the terms of the GNU Affero General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.
    
    Zotero is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU Affero General Public License for more details.
    
    You should have received a copy of the GNU Affero General Public License
    along with Zotero.  If not, see <http://www.gnu.org/licenses/>.
    
    ***** END LICENSE BLOCK *****
-->

<!DOCTYPE bindings SYSTEM "chrome://zotero/locale/zotero.dtd">
<bindings xmlns="http://www.mozilla.org/xbl"
          xmlns:html="http://www.w3.org/1999/xhtml"
		  xmlns:xbl="http://www.mozilla.org/xbl"
		  xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
	<binding id="styled-textbox">
		<implementation>
			<field name="_editable"/>
			<field name="_mode"/>
			<field name="_format"/>
			<field name="_changed"/>
			<field name="_loadHandler"/>
			<field name="_commandString"/>
			<field name="_eventHandler"/>
			<field name="_editor"/>
			<field name="_value"/>
			<field name="_timer"/>
			<field name="_focus"/>
			<field name="_constructed"/>
			<field name="_loadOnConstruct"/>
			
			<constructor><![CDATA[
				this.mode = this.getAttribute('mode');
				
				this._iframe = document.getAnonymousElementByAttribute(this, "anonid", "rt-view");
				
				// Atomic units, HTML -> RTF (cleanup)
				//[/<\/p>(?!\s*$)/g, "\\par{}"],
				//[/ /g, "&nbsp;"],
				//[/\u00A0/g, " "],
				this._htmlRTFmap = [
					[/<br \/>/g, "\x0B"],
					[/<span class=\"tab\">&nbsp;<\/span>/g, "\\tab{}"],
					[/&lsquo;/g, "‘"],
					[/&rsquo;/g, "’"],
					[/&ldquo;/g, "“"],
					[/&rdquo;/g, "”"],
					[/&nbsp;/g, "\u00A0"],
					[/"(\w)/g, "“$1"],
					[/([\w,.?!])"/g, "$1”"],
					[/<p>/g, ""],
					[/<\/?div[^>]*>/g, ""]
				];
				
				// Atomic units, RTF -> HTML (cleanup)
				this._rtfHTMLmap = [
					[/\\uc0\{?\\u([0-9]+)\}?(?:{}| )?/g, function(wholeStr, aCode) { return String.fromCharCode(aCode) }],
					[/\\tab(?:\{\}| )/g, '<span class="tab">&nbsp;</span>'],
					[/(?:\\par{}|\\\r?\n)/g, "</p><p>"]
				];
				
				this.prepare = function() {
					// DEBUG: Does this actually happen?
					if (this.prepared) return;
					
					// Tag data
					var _rexData = [
						[
							[
								["<span +style=\"font-variant: *small-caps;\">"],
								["{\\scaps ", "{\\scaps{}"]
							],
							[
								["<\/span>"],
								["}"]
							]
						],
						[
							[
								["<span +style=\"text-decoration: *underline;\">"],
								["{\\ul{}", "{\\ul "]
							],
							[
								["<\/span>"],
								["}"]
							]
						],
						[
							[
								["<sup>"],
								["\\super ", "\\super{}"]
							],
							[
								["</sup>"],
								["\\nosupersub{}", "\\nosupersub "]
							]
						],
						[
							[
								["<sub>"],
								["\\sub ", "\\sub{}"]
							],
							[
								["</sub>"],
								["\\nosupersub{}", "\\nosupersub "]
							]
						],
						[
							[
								["<em>"],
								["{\\i{}", "{\\i "]
							],
							[
								["</em>"],
								["}"]
							]
						],
						[
							[
								["<i>"],
								["{\\i{}", "{\\i "]
							],
							[
								["</i>"],
								["}"]
							]
						],
						[
							[
								["<b>"],
								["{\\b{}", "{\\b "]
							],
							[
								["</b>"],
								["}"]
							]
						],
						[
							[
								["<strong>"],
								["{\\b{}", "{\\b "]
							],
							[
								["</strong>"],
								["}"]
							]
						],
						[
							[
								["<span +style=\"font-variant: *normal;\">"],
								["{\\scaps0{}", "{\\scaps0 "]
							],
							[
								["</span>"],
								["}"]
							]
						],
						[
							[
								["<span +style=\"font-style: *normal;\">"],
								["{\\i0{}", "{\\i0 "]
							],
							[
								["</span>"],
								["}"]
							]
						],
						[
							[
								["<span +style=\"font-weight: *normal;\">"],
								["{\\b0{}", "{\\b0 "]
							],
							[
								["</span>"],
								["}"]
							]
						]
					];
					
					function longestFirst(a, b) {
						if (a.length < b.length) {
							return 1;
						} else if (a.length > b.length) {
							return -1;
						} else {
							return 0;
						}
					}
					
					function normalizeRegExpString(str) {
						if (!str) return str;
						return str.replace(/\s+/g, " ")
									.replace(/(?:[\+]|\s[\*])/g, "")
									.replace(/[\']/g, '\"')
									.replace(/:\s/g, ":");
					}

					this.normalizeRegExpString = normalizeRegExpString;
					
					function composeRex(rexes, noGlobal) {
						var lst = [];
						for (var rex in rexes) {
							lst.push(rex);
						}
						lst.sort(longestFirst);
						var rexStr = "(?:" + lst.join("|") + ")";
						return new RegExp(rexStr, "g");
					}
					
					// Create splitting regexps
					function splitRexMaker(segment) {
						var rexes = {};
						for (var i=0,ilen=_rexData.length; i < ilen; i++) {
							for (var j=0,jlen=_rexData[i].length; j < jlen; j++) {
								for (var k=0,klen=_rexData[i][j][segment].length; k < klen; k++) {
									rexes[_rexData[i][j][segment][k].replace("\\", "\\\\")] = true;
								}
							}
						}
						var ret = composeRex(rexes, true);
						return ret;
					}
					this.rtfHTMLsplitRex = splitRexMaker(1);
					this.htmlRTFsplitRex = splitRexMaker(0);
					
					// Create open-tag sniffing regexp
					function openSniffRexMaker(segment) {
						var rexes = {};
						for (var i=0,ilen=_rexData.length; i < ilen; i++) {
							for (var j=0,jlen=_rexData[i][0][segment].length; j < jlen; j++) {
								rexes[_rexData[i][0][segment][j].replace("\\", "\\\\")] = true;
							}
						}
						return composeRex(rexes);
					}
					this.rtfHTMLopenSniffRex = openSniffRexMaker(1);
					this.htmlRTFopenSniffRex = openSniffRexMaker(0);
					
					// Create open-tag remapper
					function openTagRemapMaker(segment) {
						var ret = {};
						for (var i=0,ilen=_rexData.length; i < ilen; i++) {
							var primaryVal = normalizeRegExpString(_rexData[i][0][segment][0]);
							for (var j=0,jlen=_rexData[i][0][segment].length; j < jlen; j++) {
								var key = normalizeRegExpString(_rexData[i][0][segment][j]);
								ret[key] = primaryVal;
							}
						}
						return ret;
					}

					this.rtfHTMLopenTagRemap = openTagRemapMaker(1);
					this.htmlRTFopenTagRemap = openTagRemapMaker(0);
					
					// Create open-tag-keyed close-tag sniffing regexps
					function closeTagRexMaker(segment) {
						var ret = {};
						var rexes = {};
						for (var i=0,ilen=_rexData.length; i < ilen; i++) {
							var primaryVal = _rexData[i][0][segment][0];
							for (var j=0,jlen=_rexData[i][1][segment].length; j < jlen; j++) {
								rexes[_rexData[i][1][segment][j]] = true;
							}
							ret[primaryVal] = composeRex(rexes);
						}
						return ret;
					}
					this.rtfHTMLcloseTagRex = closeTagRexMaker(1);
					this.htmlRTFcloseTagRex = closeTagRexMaker(0);
					
					// Create open-tag-keyed open/close tag registry
					function tagRegistryMaker(segment) {
						var antisegment = 1;
						if (segment == 1) {
							antisegment = 0;
						}
						var ret = {};
						for (var i=0,ilen=_rexData.length; i < ilen; i++) {
							var primaryVal = normalizeRegExpString(_rexData[i][0][segment][0]);
							ret[primaryVal] = {
								open: normalizeRegExpString(_rexData[i][0][antisegment][0]),
								close: _rexData[i][1][antisegment][0]
							}
						}
						return ret;
					}
					
					this.rtfHTMLtagRegistry = tagRegistryMaker(1);
					this.htmlRTFtagRegistry = tagRegistryMaker(0);

                    this.prepared = true;
				}
				this.prepare();
				
				this.getSplit = function(mode, txt) {
					if (!txt) return [];
					var splt = txt.split(this[mode + "splitRex"]);
					var mtch = txt.match(this[mode + "splitRex"]);
					var lst = [splt[0]];
					for (var i=1,ilen=splt.length; i < ilen; i++) {
						lst.push(mtch[i-1]);
						lst.push(splt[i]);
					}
					return lst;
				}
				
				this.getOpenTag = function(mode, str) {
					var m = str.match(this[mode + "openSniffRex"]);
					if (m) {
						m = this[mode + "openTagRemap"][this.normalizeRegExpString(m[0])];
					}
					return m;
				}
				
				this.convert = function(mode, txt) {
					var lst = this.getSplit(mode, txt);
					var sdepth = 0;
					var depth = 0;
					for (var i=1,ilen=lst.length; i < ilen; i += 2) {
						var openTag = this.getOpenTag(mode, lst[i]);
						if (openTag) {
							sdepth++;
							depth = sdepth;
							for (var j=(i+2),jlen=lst.length; j < jlen; j += 2) {
								var closeTag = !this.getOpenTag(mode, lst[j]);
								if (closeTag) {
									if (depth === sdepth && lst[j].match(this[mode + "closeTagRex"][openTag])) {
										lst[i] = this[mode + "tagRegistry"][openTag].open;
										lst[j] = this[mode + "tagRegistry"][openTag].close;
										break;
									}
									depth--;
								} else {
									depth++;
								}
							}
						} else {
							sdepth--;
						}
					}
					return lst.join("");
				}
				
				this.htmlToRTF = function(txt) {
					txt = this.convert("htmlRTF", txt);
					for (var i=0,ilen=this._htmlRTFmap.length; i < ilen; i++) {
						var entry = this._htmlRTFmap[i];
						txt = txt.replace(entry[0], entry[1]);
					}
					txt = Zotero.Utilities.unescapeHTML(txt);
					txt = txt.replace(/[\x7F-\uFFFF]/g, function(aChar) { return "\\uc0\\u"+aChar.charCodeAt(0).toString()+"{}"});
					return txt.trim();
				}
				
				this.rtfToHTML = function(txt) {
					for (var i=0,ilen=this._rtfHTMLmap.length; i < ilen; i++) {
						var entry = this._rtfHTMLmap[i];
						txt = txt.replace(entry[0], entry[1]);
					}
					txt = this.convert("rtfHTML", txt);
					return txt.trim();
				}

				this._constructed = true;
				
				// Don't load if a value hasn't yet been set
				if (this._loadOnConstruct) {
					this._load();
				}
			]]></constructor>
			
			<property name="mode">
				<getter><![CDATA[
					if (!this._mode) {
						throw ("mode is not defined in styled-textbox.xml");
					}
					return this._mode;
				]]></getter>
				<setter><![CDATA[
					Zotero.debug("Setting mode to " + val);
					switch (val) {
						case 'note':
							this._eventHandler = function (event) {
								// Necessary in Fx32+
								if (event.wrappedJSObject) {
									event = event.wrappedJSObject;
								}
								
								var commandEvent = false;
								
								if (Zotero.Prefs.get('debugNoteEvents')) {
									Zotero.debug(event.type);
									Zotero.debug(event.which);
								}
								switch (event.type) {
									case 'keydown':
										// Handle forward-delete, which doesn't register as a keypress
										// when a selection is cleared
										if (event.which == event.DOM_VK_DELETE) {
											this._changed = true;
											commandEvent = true;
										}
										break;
									
									case 'keypress':
										// Ignore keypresses that don't change
										// any text
										//Zotero.debug(event.which);
										if (!event.which &&
												event.keyCode != event.DOM_VK_DELETE &&
												event.keyCode != event.DOM_VK_BACK_SPACE) {
											//Zotero.debug("Not a char");
											return;
										}
										this._changed = true;
										commandEvent = true;
										break;
									
									// 'change' includes text added via drag-and-drop
									case 'change':
									case 'undo':
									case 'redo':
										this._changed = true;
										commandEvent = true;
										break;
									
									case 'ZoteroLinkClick':
										var zp = typeof ZoteroPane != 'undefined'
											? ZoteroPane
											: window.opener.ZoteroPane;
										zp.loadURI(event.value);
										break;
									
									default:
										return;
								}
								
								// Trigger command on change
								if (commandEvent && this.timeout) {
									if (this._timer) {
										clearTimeout(this._timer);
									}
									
									this._timer = setTimeout(function () {
										var attr = this.getAttribute('oncommand');
										attr = attr.replace('this', 'thisObj');
										var func = new Function('thisObj', 'event', attr);
										func(this, event);
									}.bind(this), this.timeout);
								}
								
								return true;
							}.bind(this);
							break;
						
						case 'integration':
							break;
							
						default:
							throw ("Invalid mode '" + val + "' in styled-textbox.xml");
					}
					return this._mode = val;
				]]></setter>
			</property>
			
			<!-- Sets or returns formatting (currently, HTML or Integration) of rich text box -->
			<property name="initialized">
				<getter><![CDATA[
					return !!this._editor;
				]]></getter>
			</property>	
			
			<!-- Sets or returns formatting (currently, HTML or Integration) of rich text box -->
			<property name="format">
				<getter><![CDATA[
					return this._format;
				]]></getter>
				<setter><![CDATA[
					return this._format = val;
				]]></setter>
			</property>
			
			<!-- Sets or returns contents of rich text box -->
			<property name="value">
				<getter><![CDATA[
					if (!this._editor) {
						return null;
					}
					
					var output = this._editor.getContent().trim();
					
					if(this._format == "RTF") {
						// strip divs
						if(output.substr(0, 5) == "<div>" && output.substr(-6) == "</div>") {
							output = output.slice(0, output.length-6).slice(5).trim();
						}
						output = this.htmlToRTF(output)
					}
					
					return output;
				]]></getter>
				<setter><![CDATA[
					if (self._timer) {
						clearTimeout(self._timer);
					}
					
					if(!this._editor) {
						Zotero.debug('No editor yet');
						
						this._value = val;
						if (!this._constructed) {
							Zotero.debug('Styled textbox not yet constructed', 2);
							this._loadOnConstruct = true;
						}
						else if (!this._loaded) {
							this._load();
						}
						return ;
					}
					
					// Hack to ignore incomplete editors due to rapid note creation in tests
					if (Zotero.test && !this._editor.getContent) {
						Zotero.logError("editor.getContent doesn't exist");
						return;
					}
					
					if (this.value == val) {
						Zotero.debug("Textbox value hasn't changed");
						this._changed = false;
						return;
					}
					
					var html = val;
					if(this._format == "RTF") {
						var bodyStyle = "";
						if(html.substr(0, 3) == "\\li") {
							// try to show paragraph formatting
							var returnIndex = html.indexOf("\r\n");
							
							var tags = html.substr(1, returnIndex).split("\\");
							html = html.substr(returnIndex+2);
							
							for(var i=0; i<tags.length; i++) {
								var tagName = tags[i].substr(0, 2);
								var tagValue = tags[i].substring(2, tags[i].length-1);
								if(tagName == "li") {
									var li = parseInt(tagValue, 10);
								} else if(tagName == "fi") {
									var fi = parseInt(tagValue, 10);
								}
							}
							
							// don't negatively indent
							if(fi < 0 && li == 0) li = -fi;
							
							bodyStyle = "margin-left:"+(li/20+6)+"pt;text-indent:"+(fi/20)+"pt;";
						}
						
						html = this.rtfToHTML(html);
						
						html = '<div style="'+bodyStyle+'"><p>'+html+"</p></div>";
					}
					
					Zotero.debug("Setting content to " + html);
					
					this._editor.setContent(html);
					this._changed = false;
					return val;
				]]></setter>
			</property>
			
			<property name="timeout"
					onset="this.setAttribute('timeout', val); return val;"
					onget="return parseInt(this.getAttribute('timeout')) || 0;"/>
			
			<property name="changed" onget="return this._changed;" onset="this._changed = !!val;"/>
			
			<method name="focus">
				<body>
				<![CDATA[
					if (this._editor) {
						this._iframe.focus();
						this._editor.focus();
						this._focus = false;
					}
					else {
						this._focus = true;
					}
				]]>
				</body>
			</method>
			
			<method name="hasFocus">
				<body>
				<![CDATA[
					return this._editor ? this._editor.hasFocus() : false;
				]]>
				</body>
			</method>
			
			<method name="clearUndo">
				<body>
				<![CDATA[
					if (this._editor) {
						this._editor.undoManager.clear();
						this._editor.undoManager.add();
					}
				]]>
				</body>
			</method>
			
			<method name="onInit">
				<parameter name="callback"/>
				<body><![CDATA[
					if (this.initialized) {
						// Hack to ignore incomplete editors due to rapid note creation in tests
						if (Zotero.test && !this._editor.setMode) {
							Zotero.logError("editor.setMode doesn't exist after initialization");
							return;
						}
						callback(this._editor);
					}
					else {
						if (!this._onInitCallbacks) {
							this._onInitCallbacks = [];
						}
						this._onInitCallbacks.push(callback);
					}
				]]></body>
			</method>
			
			<field name="_loaded"/>
			<method name="_load">
				<body>
				<![CDATA[
					this._loaded = true;
					
					// Unless we find a better way, use a separate HTML file
					// for read-only mode
					var htmlFile = this.mode + (this.getAttribute('readonly') != 'true' ? "" : "view");
					
					var url = `resource://zotero/tinymce/${htmlFile}.html`
						// Pass directionality (LTR/RTL) and locale in URL
						+ "?locale=" + encodeURIComponent(Zotero.locale)
						+ "&dir=" + Zotero.dir;
					Components.utils.import("resource://gre/modules/Services.jsm");
					var uri = Services.io.newURI(url, null, null);
					
					Zotero.debug("Loading " + uri.spec);
					
					// Register handler for deferred setting of content
					var self = this;
					var matchTo = null;
					var listener = function(e) {
						var win = self._iframe.contentWindow;
						var SJOW = win.wrappedJSObject;
						
						// only fire if the target matches, or _zoteroMatchTo, which we set last
						// time the target matched, matches
						if(e.target !== self._iframe.contentDocument
							&& (!SJOW._zoteroMatchTo || SJOW._zoteroMatchTo !== matchTo)) return;
						
						if (!SJOW.tinyMCE) {
							Zotero.getInstalledExtensions().then(function(exts) {
								for (let ext of exts) {
									if (ext.indexOf('NoScript') != -1 && ext.indexOf('disabled') == -1) {
										var doc = win.document;
										var div = doc.getElementById('tinymce');
										var warning = doc.createElement('div');
										warning.id = 'noScriptWarning';
										var str = "The NoScript extension is preventing Zotero "
											+ "from displaying notes. To use NoScript and Zotero together, "
											+ "whitelist the 'file:' scheme in the NoScript preferences "
											+ "and restart " + Zotero.appName + ".";
										warning.appendChild(document.createTextNode(str));
										div.appendChild(warning);
										break;
									}
								}
							});
							return;
						}
						
						if (!SJOW.zoteroInit) {
							SJOW.zoteroInit = function(editor) {
								// Necessary in Fx32+
								if (editor.wrappedJSObject) {
									self._editor = editor.wrappedJSObject;
								}
								else {
									self._editor = editor;
								}
								if (self._value) {
									self.value = self._value;
									
									// Prevent undoing to empty note after initialization
									self._editor.undoManager.clear();
									self._editor.undoManager.add();
								}
								if (self._focus) {
									setTimeout(function () {
										self._iframe.focus();
										self._editor.focus();
									});
									self._focus = false;
								}
								
								// Add CSS rules to notes
								if (self.mode == 'note') {
									let fontSize = Zotero.Prefs.get('note.fontSize');
									// Fix empty old font prefs before a value was enforced
									if (fontSize < 6) {
										fontSize = 11;
									}
									var css = "body#zotero-tinymce-note, "
										+ "body#zotero-tinymce-note p, "
										+ "body#zotero-tinymce-note th, "
										+ "body#zotero-tinymce-note td, "
										+ "body#zotero-tinymce-note pre { "
											+ "font-size: " + fontSize + "px; "
										+ "} "
										+ "body#zotero-tinymce-note, "
										+ "body#zotero-tinymce-note p { "
											+ "font-family: "
											+ Zotero.Prefs.get('note.fontFamily') + "; "
										+ "}"
										+ Zotero.Prefs.get('note.css');
									
									var doc = editor.contentDocument;
									var head = doc.getElementsByTagName("head")[0];
									var style = doc.createElement("style");
									style.innerHTML = css;
									head.appendChild(style);
								}
								
								let cb;
								if (this._onInitCallbacks) {
									while (cb = this._onInitCallbacks.shift()) {
										// Hack to ignore incomplete editors due to rapid note creation in tests
										if (Zotero.test && !this._editor.setMode) {
											Zotero.logError("editor.setMode doesn't exist");
											return;
										}
										cb(this._editor);
									}
								}
							}.bind(this);
						}
						
						var editor = SJOW.tinyMCE.get("tinymce");
						if (!editor) {
							Zotero.debug("editor not ready");
							
							// this is a hack; I'm not sure why we can't identify the event target
							// next time without it, but apparently we can't
							matchTo = Zotero.randomString();
							SJOW._zoteroMatchTo = matchTo;
							
							// Not ready yet
							return;
						}
						
						self._iframe.removeEventListener("DOMContentLoaded", listener, false);
						
						if (self._eventHandler) {
							win.wrappedJSObject.zoteroHandleEvent = self._eventHandler;
						}
						
						// Run Cut/Copy/Paste with chrome privileges
						win.wrappedJSObject.zoteroExecCommand = function (doc, command, ui, value) {
							return doc.execCommand(command, ui, value);
						}
					}.bind(this);
					
					this._iframe.addEventListener("DOMContentLoaded", listener, false);
					
					let loadURIOptions = {
						triggeringPrincipal: null,
						csp: null,
						loadFlags: Components.interfaces.nsIWebNavigation.LOAD_FLAGS_BYPASS_HISTORY,
						referrerInfo: null,
						postData: null,
					};
					this._iframe.webNavigation.loadURI(uri.spec, loadURIOptions);
				]]>
				</body>
			</method>
			
		</implementation>
		
		<content>
			<xul:iframe flex="1" anonid="rt-view" class="rt-view" type="content"
				xbl:inherits="onfocus,onblur,flex,width,height,hidden"
				style="overflow: hidden"/>
		</content>
	</binding>
</bindings>