2 * It's All Text - Easy external editing of web forms.
3 * Copyright 2006 Christian Höltje
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License or
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
20 // @todo [9] IDEA: dropdown list for charsets (utf-8, western-iso, default)?
21 // @todo [3] Have a menu/context menu item for turning on monitoring/watch.
22 // @todo [9] Menu item to pick the file to load into a textarea.
23 // @todo [9] Hot-keys for editing or opening the context menu.
25 var ItsAllText = function() {
27 * This data is all private, which prevents security problems and it
28 * prevents clutter and collection.
34 * Used for tracking all the all the textareas that we are watching.
40 * Keeps track of all the refreshes we are running.
43 var cron = [null]; // Eat the 0th position
46 * A constant, a string used for things like the preferences.
49 that.MYSTRING = 'itsalltext';
52 * A constant, the version number. Set by the Makefile.
55 that.VERSION = '0.7.3';
58 * A constant, the url to the readme.
61 that.README = 'chrome://itsalltext/locale/readme.xhtml';
63 /* The XHTML Namespace */
64 that.XHTMLNS = "http://www.w3.org/1999/xhtml";
66 /* The XUL Namespace */
67 that.XULNS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
70 var string_bundle = Components.classes["@mozilla.org/intl/stringbundle;1"].
71 getService(Components.interfaces.nsIStringBundleService);
73 * A localization bundle. Use it like so:
74 * ItsAllText.locale.getStringFromName('blah');
76 that.locale = string_bundle.createBundle("chrome://itsalltext/locale/itsalltext.properties");
78 * Formats a locale string, replacing $N with the arguments in arr.
79 * @param {String} name Locale property name
80 * @param {Array} arr Array of strings to replace in the string.
83 that.localeFormat = function(name, arr) {
84 return this.locale.formatStringFromName(name, arr, arr.length);
87 * Returns the locale string matching name.
88 * @param {String} name Locale property name
91 that.localeString = function(name) {
92 return this.locale.GetStringFromName(name);
96 * Create an error message from given arguments.
97 * @param {Object} message One or more objects to be made into strings...
99 that.logString = function() {
100 var args = Array.prototype.slice.apply(arguments,[0]);
101 for (var i=0; i<args.length; i++) {
103 args[i] = args[i].toString();
105 Components.utils.reportError(e);
106 args[i] = 'toStringFailed';
109 args.unshift(that.MYSTRING+':');
110 return args.join(' ');
114 * This is a handy debug message. I'll remove it or disable it when
116 * @param {Object} message One or more objects can be passed in to display.
118 that.log = function() {
119 var message = that.logString.apply(that, arguments);
120 var consoleService, e;
122 // idiom: Convert arguments to an array for easy handling.
123 consoleService = Components.
124 classes["@mozilla.org/consoleservice;1"].
125 getService(Components.interfaces.nsIConsoleService);
126 consoleService.logStringMessage(message);
128 Components.utils.reportError(message);
133 * Uses log iff debugging is turned on. Used for messages that need to
134 * globally logged (firebug only logs locally).
135 * @param {Object} message One or more objects can be passed in to display.
137 that.debuglog = function() {
138 if (that.preferences.debug) {
139 that.log.apply(that,arguments);
144 * Displays debug information, if debugging is turned on.
146 * @param {Object} message One or more objects can be passed in to display.
148 that.debug = function() {
149 if (that.preferences.debug) {
150 try { Firebug.Console.logFormatted(arguments); }
152 that.log.apply(that,arguments);
158 * A factory method to make an nsILocalFile object.
159 * @param {String} path A path to initialize the object with (optional).
160 * @returns {nsILocalFile}
162 that.factoryFile = function(path) {
163 var file = Components.
164 classes["@mozilla.org/file/local;1"].
165 createInstance(Components.interfaces.nsILocalFile);
166 if (typeof(path) == 'string' && path !== '') {
167 file.initWithPath(path);
173 * Returns the directory where we put files to edit.
174 * @returns nsILocalFile The location where we should write editable files.
176 that.getEditDir = function() {
177 /* Where is the directory that we use. */
178 var fobj = Components.classes["@mozilla.org/file/directory_service;1"].
179 getService(Components.interfaces.nsIProperties).
180 get("ProfD", Components.interfaces.nsIFile);
181 fobj.append(that.MYSTRING);
182 if (!fobj.exists()) {
183 fobj.create(Components.interfaces.nsIFile.DIRECTORY_TYPE,
186 if (!fobj.isDirectory()) {
187 that.error(that.localeFormat('problem_making_directory', [fobj.path]));
193 * Cleans out the edit directory, deleting all old files.
195 that.cleanEditDir = function(force) {
196 force = (force && typeof(force) != 'undefined');
197 var last_week = Date.now() - (1000*60*60*24*7);
198 var fobj = that.getEditDir();
199 var entries = fobj.directoryEntries;
201 while (entries.hasMoreElements()) {
202 entry = entries.getNext();
203 entry.QueryInterface(Components.interfaces.nsIFile);
204 if(force || !entry.exists() || entry.lastModifiedTime < last_week){
208 that.debug('unable to remove',entry,'because:',e);
214 /* Clean the edit directory whenever we create a new window. */
217 /* Load the various bits needed to make this work. */
218 var loader = Components.classes["@mozilla.org/moz/jssubscript-loader;1"].getService(Components.interfaces.mozIJSSubScriptLoader);
219 loader.loadSubScript('chrome://itsalltext/content/Color.js', that);
220 loader.loadSubScript('chrome://itsalltext/content/cacheobj.js', that);
223 * Dictionary for storing the preferences in.
230 * Fetches the current value of the preference.
232 * @param {String} aData The name of the pref to fetch.
233 * @returns {Object} The value of the preference.
235 _get: function(aData) {
236 var po = that.preference_observer;
237 return po._branch['get'+(po.types[aData])+'Pref'](aData);
241 * Sets the current preference.
242 * @param {String} aData The name of the pref to change.
243 * @param {Object} value The value to set.
245 _set: function(aData, value) {
246 var po = that.preference_observer;
247 return po._branch['set'+(po.types[aData])+'Pref'](aData, value);
252 * A Preference Observer.
254 that.preference_observer = {
256 * Dictionary of types (well, really the method needed to get/set the
265 disable_gumdrops: 'Bool',
270 * Register the observer.
272 register: function() {
273 var prefService = Components.
274 classes["@mozilla.org/preferences-service;1"].
275 getService(Components.interfaces.nsIPrefService);
276 this._branch = prefService.getBranch("extensions."+that.MYSTRING+".");
277 this._branch.QueryInterface(Components.interfaces.nsIPrefBranch2);
278 this._branch.addObserver("", this, false);
279 /* setup the preferences */
280 for(var type in this.types) {
281 that.preferences[type] = that.preferences._get(type);
286 * Unregister the observer. Not currently used, but may be
287 * useful in the future.
289 unregister: function() {
290 if (!this._branch) {return;}
291 this._branch.removeObserver("", this);
295 * Observation callback.
296 * @param {String} aSubject The nsIPrefBranch we're observing (after appropriate QI)e
297 * @param {String} aData The name of the pref that's been changed (relative to the aSubject).
298 * @param {String} aTopic The string defined by NS_PREFBRANCH_PREFCHANGE_TOPIC_ID
300 observe: function(aSubject, aTopic, aData) {
301 if (aTopic != "nsPref:changed") {return;}
302 if (that.preferences) {
303 that.preferences[aData] = that.preferences._get(aData);
304 if (aData == 'refresh') {
305 that.monitor.restart();
312 * A Preference Option: What character set should the file use?
313 * @returns {String} the charset to be used.
315 that.getCharset = function() {
316 return that.preferences.charset;
320 * A Preference Option: How often should we search for new content?
321 * @returns {int} The number of seconds between checking for new content.
323 that.getRefresh = function() {
324 var refresh = that.preferences.refresh;
325 if (!refresh || refresh < 1) {
326 that.debug('Invalid refresh gotten:',refresh);
329 var retval = 1000*refresh;
335 * Returns true if the system is running Mac OS X.
336 * @returns {boolean} Is this a Mac OS X system?
338 that.isDarwin = function() {
340 http://developer.mozilla.org/en/docs/Code_snippets:Miscellaneous#Operating_system_detection
343 var is_darwin = that._is_darwin;
344 if (typeof(is_darwin) == 'undefined') {
345 is_darwin = /^Darwin/i.test(Components.classes["@mozilla.org/xre/app-info;1"].getService(Components.interfaces.nsIXULRuntime).OS);
346 that._is_darwin = is_darwin;
352 * A Preference Option: What editor should we use?
354 * Note: On some platforms, this can return an
355 * NS_ERROR_FILE_INVALID_PATH exception and possibly others.
357 * For a complete list of exceptions, see:
358 * http://lxr.mozilla.org/seamonkey/source/xpcom/base/nsError.h#262
359 * @returns {nsILocalFile} A file object of the editor.
361 that.getEditor = function() {
362 var editor = that.preferences.editor;
365 if (editor === '' && that.isDarwin()) {
366 editor = '/usr/bin/open';
367 that.preferences._set('editor', editor);
371 retval = that.factoryFile(editor);
377 * A Preference Option: should we display debugging info?
380 that.getDebug = function() {
381 return that.preferences.debug;
385 * A Preference Option: Are the edit gumdrops disabled?
388 that.getDisableGumdrops = function() {
389 return that.preferences.disable_gumdrops;
393 * A Preference Option: The list of extensions
396 that.getExtensions = function() {
397 var string = that.preferences.extensions.replace(/[\n\t ]+/g,'');
398 var extensions = string.split(',');
399 if (extensions.length === 0) {
407 * Open the preferences dialog box.
408 * @param{boolean} wait The function won't return until the preference is set.
410 * Borrowed from http://wiki.mozilla.org/XUL:Windows
411 * and utilityOverlay.js's openPreferences()
413 that.openPreferences = function (wait) {
414 wait = typeof(wait)=='boolean'?wait:false;
415 var paneID = that.MYSTRING + '_preferences';
416 var instantApply = getBoolPref("browser.preferences.instantApply", false) && !wait;
417 var features = "chrome,titlebar,toolbar,centerscreen" + (instantApply ? ",dialog=no" : ",modal");
419 var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"].getService(Components.interfaces.nsIWindowMediator);
420 var win = wm.getMostRecentWindow("Browser:Preferences");
425 pane = win.document.getElementById(paneID);
426 win.document.documentElement.showPane(pane);
429 openDialog('chrome://itsalltext/chrome/preferences.xul',
430 "", features, paneID);
435 * A Preference Option: Append an extension
438 that.appendExtensions = function(ext) {
439 ext = ext.replace(/[\n\t ]+/g,'');
440 var current = that.getExtensions();
441 for(var i=0; i<current.length; i++) {
442 if(ext == current[i]) {
443 return; // Don't add a duplicate.
447 var value = that.preferences.extensions;
448 if(value.replace(/[\t\n ]+/g) === '') {
451 value = [value,',',ext].join('');
453 that.preferences._set('extensions', value);
456 // @todo [3] Profiling and optimization.
459 * Returns a cache object
460 * Note: These UIDs are only unique for Its All Text.
461 * @param {Object} node A dom object node or ID to one.
462 * @returns {String} the UID or null.
464 that.getCacheObj = function(node) {
466 var str = that.MYSTRING+"_UID";
467 if (typeof(node) == 'string') {
468 cobj = that.tracker[node];
470 if (node && node.hasAttribute(str)) {
471 cobj = that.tracker[node.getAttribute(str)];
474 cobj = new ItsAllText.CacheObj(node);
481 * Cleans out all old cache objects.
483 that.cleanCacheObjs = function() {
486 for(id in that.tracker) {
487 cobj = that.tracker[id];
488 if (cobj.node.ownerDocument.location === null) {
489 that.debug('cleaning %s', id);
492 delete that.tracker[id];
497 that.debuglog('tracker count:', count);
502 * @param {Object} node A specific textarea dom object to update.
504 that.refreshTextarea = function(node, is_chrome) {
505 var cobj = ItsAllText.getCacheObj(node);
506 if(!cobj) { return; }
509 if (!is_chrome) { cobj.addGumDrop(); }
512 // @todo [5] Refresh textarea on editor quit.
513 // @todo [9] IDEA: support for input elements as well?
517 * @param {Object} doc The document to refresh.
519 that.refreshDocument = function(doc) {
520 if(!doc.location) { return; } // it's being cached, but not shown.
521 var is_chrome = (doc.location.protocol == 'chrome:' &&
522 doc.location.href != that.README);
523 var nodes = doc.getElementsByTagName('textarea');
525 for(i=0; i < nodes.length; i++) {
526 that.refreshTextarea(nodes[i], is_chrome);
528 nodes = doc.getElementsByTagName('textbox');
529 for(i=0; i < nodes.length; i++) {
530 that.refreshTextarea(nodes[i], is_chrome);
535 * Returns the offset from the containing block.
536 * @param {Object} node A DOM element.
537 * @param {Object} container If unset, then this will use the offsetParent of node. Pass in null to go all the way to the root.
538 * @return {Array} The X & Y page offsets
540 that.getContainingBlockOffset = function(node, container) {
541 if(typeof(container) == 'undefined') {
542 container = node.offsetParent;
544 var pos = [node.offsetLeft, node.offsetTop];
545 var pnode = node.offsetParent;
546 while(pnode && (container === null || pnode != container)) {
547 pos[0] += pnode.offsetLeft || 0;
548 pos[1] += pnode.offsetTop || 0;
549 pos[0] -= pnode.scrollLeft || 0;
550 pos[1] -= pnode.scrollTop || 0;
551 pnode = pnode.offsetParent;
558 * This function is called regularly to watch changes to web documents.
565 * Starts or restarts the document monitor.
567 restart: function() {
568 var rate = that.getRefresh();
569 var id = that.monitor.id;
573 that.monitor.id = setInterval(that.monitor.watcher, rate);
576 * watches the document 'doc'.
577 * @param {Object} doc The document to watch.
579 watch: function(doc, force) {
580 var contentType, location, is_html, is_usable, is_my_readme;
582 /* Check that this is a document we want to play with. */
583 contentType = doc.contentType;
584 location = doc.location;
585 is_html = (contentType=='text/html' ||
586 contentType=='text/xhtml' ||
587 contentType=='application/xhtml+xml');
588 //var is_xul=(contentType=='application/vnd.mozilla.xul+xml');
589 is_usable = (is_html) &&
590 location.protocol != 'about:' &&
591 location.protocol != 'chrome:';
592 is_my_readme = location.href == that.README;
593 if (!(is_usable || is_my_readme)) {
594 that.debuglog('watch(): ignoring -- ',
595 location, contentType);
600 that.refreshDocument(doc);
601 that.monitor.documents.push(doc);
604 * Callback to be used by restart()
607 watcher: function(offset) {
608 var monitor = that.monitor;
609 var rate = that.getRefresh();
611 var now = Date.now();
612 if (now - monitor.last_now < Math.round(rate * 0.9)) {
613 that.debuglog('monitor.watcher(',offset,') -- skipping catchup refresh');
616 monitor.last_now = now;
618 /* Walk the documents looking for changes */
619 var documents = monitor.documents;
620 that.debuglog('monitor.watcher(',offset,'): ', documents.length);
622 var did_delete = false;
623 for(i in documents) {
626 that.debuglog('refreshing', doc.location);
627 that.refreshDocument(doc);
632 * Stops watching doc.
633 * @param {Object} doc The document to watch.
635 unwatch: function(doc) {
636 var documents = that.monitor.documents;
638 for(i in documents) {
639 if (documents[i] === doc) {
640 that.debug('unwatching', doc);
644 that.cleanCacheObjs();
645 for(i=documents.length - 1; i >= 0; i--) {
646 if(typeof(documents[i]) == 'undefined') {
647 documents.splice(i,1);
654 * Callback whenever the DOM content in a window or tab is loaded.
655 * @param {Object} event An event passed in.
657 that.onDOMContentLoad = function(event) {
658 if (event.originalTarget.nodeName != "#document") { return; }
659 var doc = event.originalTarget || document;
660 that.monitor.watch(doc);
665 * Open the editor for a selected node.
666 * @param {Object} node The textarea to get.
668 that.onEditNode = function(node) {
669 var cobj = that.getCacheObj(node);
677 * Triggered when the context menu is shown.
678 * @param {Object} event The event passed in by the event handler.
680 that.onContextMenu = function(event) {
681 var tid, node, tag, is_disabled, cobj, menu;
683 tid = event.target.id;
684 if (tid == "itsalltext-context-popup" ||
685 tid == "contentAreaContextMenu") {
686 node = document.popupNode;
687 tag = node.nodeName.toLowerCase();
688 is_disabled = (!(tag == 'textarea' ||
690 node.style.display == 'none' ||
691 node.getAttribute('readonly') ||
692 node.getAttribute('disabled')
694 if (tid == "itsalltext-context-popup") {
695 cobj = that.getCacheObj(node);
696 that.rebuildMenu(cobj.uid,
697 'itsalltext-context-popup',
700 // tid == "contentAreaContextMenu"
701 menu = document.getElementById("itsalltext-contextmenu");
702 menu.setAttribute('hidden', is_disabled);
710 that.openReadme = function() {
711 browser = getBrowser();
712 browser.selectedTab = browser.addTab(that.README, null);
716 * Initialize the module. Should be called once, when a window is loaded.
719 var windowload = function(event) {
720 that.debug("startup(): It's All Text! is watching this window...");
722 // Start watching the preferences.
723 that.preference_observer.register();
726 that.monitor.restart();
728 var appcontent = document.getElementById("appcontent"); // The Browser
731 appcontent.addEventListener("DOMContentLoaded", that.onDOMContentLoad,
734 that.onDOMContentLoad(event);
736 // Attach the context menu, if we can.
737 var contentAreaContextMenu = document.getElementById("contentAreaContextMenu");
738 if (contentAreaContextMenu) {
739 contentAreaContextMenu.addEventListener("popupshowing",
740 that.onContextMenu, false);
744 // Do the startup when things are loaded.
745 window.addEventListener("load", windowload, true);
746 // Do the startup when things are unloaded.
747 window.addEventListener("unload", function(event){that.monitor.unwatch(event.originalTarget||document); that.preference_observer.unregister();}, true);
752 * The command that is called when picking a new extension.
753 * @param {Event} event
755 ItsAllText.prototype.menuNewExtEdit = function(event) {
757 var uid = this._current_uid;
758 var cobj = that.getCacheObj(uid);
760 var params = {out:null};
761 window.openDialog("chrome://itsalltext/chrome/newextension.xul", "",
762 "chrome, dialog, modal, resizable=yes", params).focus();
765 ext = params.out.extension.replace(/[\n\t ]+/g,'');
766 if(params.out.do_save) {
767 that.appendExtensions(ext);
774 * The command that is called when selecting an existing extension.
775 * @param {Event} event
777 ItsAllText.prototype.menuExtEdit = function(event) {
779 var uid = that._current_uid;
780 var ext = event.target.getAttribute('label');
781 var cobj = that.getCacheObj(uid);
786 * Rebuilds the option menu, to reflect the current list of extensions.
788 * @param {String} uid The UID to show in the option menu.
790 ItsAllText.prototype.rebuildMenu = function(uid, menu_id, is_disabled) {
791 menu_id = typeof(menu_id) == 'string'?menu_id:'itsalltext-optionmenu';
792 is_disabled = (typeof(is_disabled) == 'undefined'||!is_disabled)?false:(is_disabled&&true);
795 var exts = that.getExtensions();
796 var menu = document.getElementById(menu_id);
797 var items = menu.childNodes;
798 var items_length = items.length - 1; /* We ignore the preferences item */
800 that._current_uid = uid;
801 var magic_stop_node = null;
802 var magic_start = null;
803 var magic_stop = null;
805 // Find the beginning and end of the magic replacement parts.
806 for(i=0; i<items_length; i++) {
808 if (node.nodeName.toLowerCase() == 'menuseparator') {
809 if(magic_start === null) {
811 } else if (magic_stop === null) {
813 magic_stop_node = node;
815 } else if (node.nodeName.toLowerCase() == 'menuitem') {
816 node.setAttribute('disabled', is_disabled?'true':'false');
820 // Remove old magic bits
821 for(i = magic_stop - 1; i > magic_start; i--) {
822 menu.removeChild(items[i]);
825 // Insert the new magic bits
826 for(i=0; i<exts.length; i++) {
827 node = document.createElementNS(that.XULNS, 'menuitem');
828 node.setAttribute('label', exts[i]);
829 node.addEventListener('command', function(event){return that.menuExtEdit(event);}, false);
830 node.setAttribute('disabled', is_disabled?'true':'false');
831 menu.insertBefore(node, magic_stop_node);
837 ItsAllText = new ItsAllText();