// mimulus.js - XHTML Editor // For all your immersive hypertext editing needs. // $Id: mimulus.js 332 2006-10-27 18:27:51Z sbp $ // // Author: Sean B. Palmer, inamidst.com // Author: Christopher Schmidt, crschmidt.net // Tested on Firefox 1.5 under OS X and Windows // // Documentation: http://inamidst.com/mimulus/ // Install: http://inamidst.com/mimulus/mimulus.xpi // // Logos designed by Cody Woodard, d8uv.org // As noted inline, portions were derived from: // * Bitflux Editor, http://bitfluxeditor.org/ // * TiddlyWiki, http://www.tiddlywiki.com/ // // License: http://inamidst.com/mimulus/license // (BSD style, sans infamous advertising clause) var Mimulus = { // A singleton class. Avoids littering the global namespace. /* @@ Todo: * Allow plain text editing? (R2) * Get the browser to scroll to follow the caret (B1) * Classes for tabs where mimulus is on (R4) * Using Ctrl+K on any element (R3) * Prevent caret flash using removeAllRanges (or somesuch) * Don't prompt when navigating away from a saved page? * Does this still happen? "If you've got an inline element at the end of a paragraph then you'll probably have to remove it to enable you to add other stuff after it." */ miniListener: function(e) { // When Mimulus first loads, the only thing that it listens for is // Ctrl+I, which toggles Mimulus editing mode on and off. if (e.ctrlKey) { switch(String.fromCharCode(e.charCode)) { case 'i': // for "insert" Mimulus.modcall(Mimulus.toggle, e); break; } } }, mainListener: function(e) { // When Mimulus is turned on, it listens for various key combinations, // mapping those to editing functions. @@ Caret browsing should only be // enabled when Mimulus is, but that's not consistent at the moment. Mimulus.selection = Mimulus.win.getSelection(); try { Mimulus.range = Mimulus.selection.getRangeAt(0); } catch(e) { return false; } if (e.metaKey) { // The command key on OS X return false; } else if (e.ctrlKey) { // @@ Enable use of Alt on Windows? switch(String.fromCharCode(e.charCode)) { // Forbidden Donut: // Ctrl+A (Select All) // Ctrl+C (Copy) // Ctrl+X (Cut) // Ctrl+V (Paste) // Ctrl+B (Bookmarks) // Ctrl+Q (Quit) // Ctrl+W (Close Tab) // Ctrl+F (Find) // Ctrl+T (New Tab) // Editor Functions case 'i': // for "insert" Mimulus.modcall(Mimulus.toggle, e); Mimulus.setModified(false); break; case 's': // for "save" Mimulus.modcall(Mimulus.save, e); Mimulus.setModified(false); break; case 'v': // for "vpaste" :-) Mimulus.modcall(Mimulus.paste, e); Mimulus.setModified(true); break; case 'h': // for "HTML edit" Mimulus.modcall(Mimulus.editSourceToggle, e); Mimulus.setModified(false); break; // Inline-Level Functions case 'e': // for "emphasize" Mimulus.modcall(Mimulus.emphasis, e); Mimulus.setModified(true); break; case 'r': // for "really emphasize" Mimulus.modcall(Mimulus.strong, e); Mimulus.setModified(true); break; case 'l': // for "link" Mimulus.modcall(Mimulus.anchor, e); Mimulus.setModified(true); break; case 'y': // for "yank inline" Mimulus.modcall(Mimulus.removeInline, e); Mimulus.setModified(true); break; // Block-level Functions case 'n': // for "new paragraph" Mimulus.modcall(Mimulus.paragraph, e); Mimulus.setModified(true); break; case 'u': // for "unordered list" Mimulus.modcall(Mimulus.unorderedList, e); Mimulus.setModified(true); break; case 'p': // for "preformatted" Mimulus.modcall(Mimulus.preformatted, e); Mimulus.setModified(true); break; case '1': // for "1st level heading Mimulus.heading(1); e.preventDefault(); e.stopPropagation(); Mimulus.setModified(true); break; case '2': // for "2nd level heading" Mimulus.heading(2); e.preventDefault(); e.stopPropagation(); Mimulus.setModified(true); break; case '3': // for "3rd level heading" Mimulus.heading(3); e.preventDefault(); e.stopPropagation(); Mimulus.setModified(true); break; case 'j': // for "joggle" Mimulus.modcall(Mimulus.togglePreformatting, e); Mimulus.setModified(true); break; case 'k': // for "kpreformat" :-) Mimulus.modcall(Mimulus.editElement, e); Mimulus.setModified(true); break; // @@ Ctrl+O or something for secondary commands case 'm': Mimulus.modcall(Mimulus.scrollDown, e); break; } } else { // Regular, non-modifier, keypress switch (e.keyCode) { case e.DOM_VK_RETURN: Mimulus.keycall(Mimulus.doReturn, e); Mimulus.setModified(true); break; case e.DOM_VK_BACK_SPACE: Mimulus.keycall(Mimulus.doBackspace, e); Mimulus.setModified(true); break; case e.DOM_VK_DELETE: Mimulus.keycall(Mimulus.doDelete, e); Mimulus.setModified(true); break; default: if (e.which === 0) { break; } var editpre = Mimulus.doc.mimulusEditElement; if (!editpre || Mimulus.getAnyParent()) { var text = String.fromCharCode(e.charCode); Mimulus.insertString(text); e.preventDefault(); e.stopPropagation(); } var node = Mimulus.selection.focusNode; var offset = Mimulus.selection.focusOffset; Mimulus.selection.collapse(node, offset); Mimulus.setModified(true); } } }, sourceListener: function(e) { // On Ctrl+H, this will become The Listener, enabling people to // edit the source of their documents. Mimulus.selection = Mimulus.win.getSelection(); try { Mimulus.range = Mimulus.selection.getRangeAt(0); } catch(e) { return false; } if (e.metaKey) { // The command key on OS X return false; } else if (e.ctrlKey) { switch(String.fromCharCode(e.charCode)) { case 's': // for "save" Mimulus.saveSource(false); // Manual save e.preventDefault(); e.stopPropagation(); Mimulus.setModified(false); break; case 'h': // for "HTML edit" Mimulus.editSourceToggle(); e.preventDefault(); e.stopPropagation(); Mimulus.setModified(true); break; } } }, keycall: function(method, e) { // Call the appropriate method for the normal keypress event var editpre = Mimulus.doc.mimulusEditElement; if (editpre && !Mimulus.getAnyParent()) { return false; // We're editing a pre-textarea } method(); e.preventDefault(); e.stopPropagation(); return true; }, modcall: function(method, e) { // Call the appropriate method for the modifier keypress event method(); e.preventDefault(); e.stopPropagation(); return true; }, scrollDown: function() { // Components.interfaces.nsIEditor // var comp = Components.classes['@mozilla.org/editor/texteditor;1']; // var e = comp.createInstance(Components.interfaces.nsIEditor); // @@.scrollSelectionIntoView(Mimulus.selection, Mimulus.range, true); }, setModified: function(bool) { // Set whether the document has unsaved modifications Mimulus.doc.mimulusModified = bool; }, validSelection: function() { // @@ This isn't actually used anywhere! Mimulus.selection = Mimulus.win.getSelection(); try { Mimulus.range = Mimulus.selection.getRangeAt(0); } catch(e) { return false; } return true; }, selectEnd: function(node) { var child = node.lastChild; Mimulus.selection.collapse(child, child.length); }, somethingSelected: function() { // Return true if something is selected, false otherwise Mimulus.selection = Mimulus.win.getSelection(); try { Mimulus.range = Mimulus.selection.getRangeAt(0); } catch(e) {} if (Mimulus.range.toString().length > 0) { return true; } else { return false; } // if ((Mimulus.range.startContainer != Mimulus.range.endContainer) || // (Mimulus.range.startOffset != Mimulus.range.endOffset)) { // return false; // } else { return true; } }, startOfElement: function() { // Return true if the caret is at the start of the parent element var here = Mimulus.range.startContainer; var parent = here.parentNode; var pos = Mimulus.range.startOffset; if (here == parent.firstChild && pos == 0) { return true; } else { return false; } }, endOfElement: function() { // Return true if the caret is at the end of the parent element var here = Mimulus.range.startContainer; var parent = here.parentNode; var pos = Mimulus.range.startOffset; if (here == parent.lastChild && pos == here.length) { return true; } else { return false; } }, paste: function() { // Cf. http://www.mozilla.org/xpfe/xptoolkit/clipboard.html var clip = Components.classes['@mozilla.org/widget/clipboard;1']. getService(Components.interfaces.nsIClipboard); var trans = Components.classes['@mozilla.org/widget/transferable;1']. getService(Components.interfaces.nsITransferable); if (clip && trans) { // This only needs to be set once, and will error if reset // We just catch the error try { trans.addDataFlavor('text/unicode'); } catch(e) {} clip.getData(trans, clip.kGlobalClipboard); var transData = new Object(); var length = new Object(); trans.getTransferData('text/unicode', transData, length); if (transData) { var value = transData.value. QueryInterface(Components.interfaces.nsISupportsString); } else { return; } // The transData value is stored as UTF-16, and the length given // is the length of internal bytes, so the actual length of the // data (which we extract below) is half that figure. // if (value && value.data) { var data = value.data.substring(0, length.value/2); Mimulus.insertString(data); } } }, insertString: function(s) { // Inserts character data into the document, escaping <, >, and & var parent = Mimulus.getAnyParent(); // The space/  hack for empty block elements if (parent.innerHTML == ' ') { // The HTML needs to be escaped when using innerHTML... s = s.replace(/&/g, '&'); s = s.replace(//g, '>'); // just for consistency if (s.substr(s.length - 1) == ' ') { s = s.substr(0, s.length - 1) + ' '; } parent.innerHTML = s; Mimulus.selectEnd(parent); return; } // The trailing space/  hack for block elements if (Mimulus.endOfElement()) { // If we're at the end of the current container, then... var html = parent.innerHTML; // The following must really be "== null". Doing a boolean test // won't work because '' counts. if (html == null) { return false; } // @@ in non-editable contexts // If the element ends in an  , replace it with a space if (html.substr(html.length - 6) == ' ' && html.length > 6) { parent.innerHTML = html.substr(0, html.length - 6) + ' '; Mimulus.selectEnd(parent); Mimulus.range = Mimulus.selection.getRangeAt(0); } if (s.substr(s.length - 1) == ' ') { parent.innerHTML = html + s.substr(0, s.length - 1) + ' '; Mimulus.selectEnd(parent); return true; } } try { Mimulus.range.deleteContents(); } catch(e) { return; } // We'll be checking that insertData didn't advance the caret var startOffset = Mimulus.range.startOffset; if (Mimulus.range.startContainer.nodeType == Mimulus.range.startContainer.TEXT_NODE) { Mimulus.range.startContainer.insertData(startOffset, s); // The following basically advances the caret // @@ When selecting across elements, Mimulus.selects everything if (startOffset == Mimulus.range.startOffset) { Mimulus.selection.collapse(Mimulus.range.startContainer, Mimulus.range.startOffset + s.length); } } // As Firebug puts it: "Mimulus.relection has no properties" // In other words, what was the following code actually for? // Mimulus.selection.addRange(Mimulus.range); return s; }, selectNode: function(node) { // @@ This is only used a few times. Refactor it? // http://www.opendarwin.org/pipermail/webkit-changes/2006-January.txt if (Mimulus.selection.selectAllChildren) { Mimulus.selection.selectAllChildren(node); } }, findParent: function(tagnames) { // Get parent of node. E.g. Mimulus.findParent(['p', 'pre']); var node = Mimulus.range.startContainer; var matches = Object(); for (var i in tagnames) { matches[tagnames[i]] = 1; } while (true) { if (node.nodeType == node.TEXT_NODE) { node = node.parentNode; continue; } else if (node == Mimulus.doc.documentElement) { return false; } var name = node.tagName.toLowerCase(); if (name in matches) { break; } else { node = node.parentNode; } } return node; }, findAnyParent: function() { // Get parent of node var node = Mimulus.range.startContainer; while (true) { if (node.nodeType == node.TEXT_NODE) { node = node.parentNode; continue; } else if (node == Mimulus.doc.documentElement) { return false; } return node.tagName.toLowerCase(); } }, getMainParent: function() { // Get parents we can put siblings after return Mimulus.findParent(['p', 'pre', 'ul', 'h1', 'h2', 'h3']); }, getAnyParent: function() { // This list of elements is not exhaustive, apparently. Chris added // them, and I'm not sure what metric for inclusion he used! var tags = ['a', 'address', 'blockquote', 'center', 'dd', 'div', 'dir', 'dl', 'dt', 'em', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'p', 'pre', 'span', 'strong', 'table', 'td']; return Mimulus.findParent(tags); }, newParagraph: function() { var p = Mimulus.doc.createElement('p'); p.innerHTML = ' '; var parent = Mimulus.getMainParent(); var grandparent = parent.parentNode; if (parent.nextSibling) { grandparent.insertBefore(p, parent.nextSibling); } else { grandparent.appendChild(p); } Mimulus.selection.collapse(p.firstChild, 0); Mimulus.autosave(); }, newParagraphBefore: function() { var p = Mimulus.doc.createElement('p'); p.innerHTML = ' '; var parent = Mimulus.getMainParent(); var grandparent = parent.parentNode; grandparent.insertBefore(p, parent); Mimulus.selection.collapse(p.firstChild, 0); Mimulus.autosave(); }, paragraph: function() { // Create a new paragraph, if the conditions are right var tags = ['h1', 'h2', 'h3', 'li', 'p', 'pre']; var parent = Mimulus.findParent(tags); if (!parent) { return false; } // @@ Show error? var name = parent.tagName.toLowerCase(); if (name == 'pre') { Mimulus.newParagraph(); } else if (Mimulus.endOfElement()) { // @@ Doesn't work in an   paragraph // Possibly for the best, but... Mimulus.newParagraph(); } }, newListItem: function() { var li = Mimulus.doc.createElement('li'); li.innerHTML = ' '; var parent = Mimulus.findParent(['li']); var grandparent = parent.parentNode; if (parent.nextSibling) { grandparent.insertBefore(li, parent.nextSibling); } else { grandparent.appendChild(li); } Mimulus.selection.collapse(li.firstChild, 0); Mimulus.autosave(); }, newListItemBefore: function() { var li = Mimulus.doc.createElement('li'); li.innerHTML = ' '; var parent = Mimulus.findParent(['li']); var grandparent = parent.parentNode; grandparent.insertBefore(li, parent); Mimulus.selection.collapse(li.firstChild, 0); Mimulus.autosave(); }, newUnorderedList: function() { var ul = Mimulus.doc.createElement('ul'); var li = Mimulus.doc.createElement('li'); li.innerHTML = ' '; ul.appendChild(li); var parent = Mimulus.getMainParent(); var grandparent = parent.parentNode; if (parent.nextSibling) { grandparent.insertBefore(ul, parent.nextSibling); } else { grandparent.appendChild(ul); } Mimulus.selection.collapse(li.firstChild, 0); Mimulus.autosave(); }, unorderedList: function() { // Make a new unordered list, if the conditions are right var tags = ['h1', 'h2', 'h3', 'p', 'pre']; var parent = Mimulus.findParent(tags); if (!parent) { return false; } // @@ Show error? var name = parent.tagName.toLowerCase(); if (name == 'pre') { Mimulus.newUnorderedList(); // } else if (name == 'li') { // // @@! Should sequential ULs be allowed? How about inline ULs? // mimulusNewInlineUnorderedList(); } else if (Mimulus.endOfElement()) { Mimulus.newUnorderedList(); } }, newPreformatted: function() { var pre = Mimulus.doc.createElement('pre'); pre.innerHTML = '\n'; var parent = Mimulus.getMainParent(); var grandparent = parent.parentNode; if (parent.nextSibling) { grandparent.insertBefore(pre, parent.nextSibling); } else { grandparent.appendChild(pre); } Mimulus.selection.collapse(pre.firstChild, 0); Mimulus.autosave(); }, preformatted: function() { // Create a new preformatted section, if the conditions are right var parent = Mimulus.findParent(['h1', 'h2', 'h3', 'li', 'p']); if (!parent) { return false; } // @@ Show error? var name = parent.tagName.toLowerCase(); // No pre-special-case needed here! if (Mimulus.endOfElement()) { Mimulus.newPreformatted(); } }, newHeading: function(level) { // We only allow the first three levels of headers if (!(level == 1 || level == 2 || level == 3)) { return false; } var h = Mimulus.doc.createElement('h' + level); h.innerHTML = ' '; var parent = Mimulus.getMainParent(); var grandparent = parent.parentNode; if (parent.nextSibling) { grandparent.insertBefore(h, parent.nextSibling); } else { grandparent.appendChild(h); } Mimulus.selection.collapse(h.firstChild, 0); Mimulus.autosave(); }, heading: function(level) { // We only allow the first three levels of headers if (!(level == 1 || level == 2 || level == 3)) { return false; } var parent = Mimulus.findParent(['h1', 'h2', 'h3', 'li', 'p', 'pre']); if (!parent) { return false; } // @@ Show error? var name = parent.tagName.toLowerCase(); if (name == 'pre') { Mimulus.newHeading(level); } else if (Mimulus.endOfElement()) { Mimulus.newHeading(level); } }, newInline: function(tag) { // Create a new inline element of name tag. // This might turn existing text content into an inline element. // If we're already in this kind of inline element, exit var parent = Mimulus.getAnyParent(); if (parent.tagName.toLowerCase() == tag) { Mimulus.exitElement(parent); return false; } // If something was selected, use that as the new content // Warning! If you globally scope the content variable, really bad // things happen, like... crashing and stuff. So don't do that. if (Mimulus.somethingSelected()) { var selection = Mimulus.range.toString(); // @@ if (selection.length > 0) { var content = Mimulus.doc.createTextNode(selection); try { Mimulus.range.deleteContents(); } catch(e) {} } else { var content = null; } var container = Mimulus.range.startContainer; var offset = Mimulus.range.startOffset; // We can only make inline elements inside text nodes if (container.nodeType == container.TEXT_NODE) { // Trailing   hack if (offset == container.length) { if (container.data.substr(container.length - 1) == '\u00A0') { container.replaceData(container.length - 1, 1, ' '); } } container.splitText(offset); var inline = Mimulus.doc.createElement(tag); if (!content) { inline.innerHTML = ' '; } else { inline.appendChild(content); } var parent = container.parentNode; var next = container.nextSibling; parent.insertBefore(inline, next); if (!content) { Mimulus.selection.collapse(inline.firstChild, 0); } else { Mimulus.selection.collapse(content, content.length); // Mimulus.selection.addRange(Mimulus.range); } return inline; } else { alert('Unable to make an inline element here'); return false; } }, emphasis: function() { Mimulus.newInline('em'); }, strong: function() { Mimulus.newInline('strong'); }, anchor: function() { var uri = window.prompt('URI', 'http://'); var a = Mimulus.newInline('a'); a.setAttribute('href', uri); }, togglePreformatting: function() { var parent = Mimulus.findParent(['p', 'pre']); if (!parent) { return false; } // @@ show error? var name = parent.tagName.toLowerCase(); if (name == 'p') { Mimulus.transformElement(parent, 'pre'); } else { Mimulus.transformElement(parent, 'p'); } }, transformElement: function(parent, tag) { var grandparent = parent.parentNode; var node = Mimulus.doc.createElement(tag); for (var i=0; child = parent.childNodes.item(i); i++) { node.appendChild(child.cloneNode(true)); } grandparent.replaceChild(node, parent); // @@ selectEnd(node), selectBeginning(node) if (node.innerHTML == ' ') { // @@ Only from p to pre, no? node.innerHTML = '\n'; Mimulus.selection.collapse(node.firstChild, 0); } else { Mimulus.selection.collapse(node.lastChild, node.lastChild.length); } Mimulus.updateStatus(); }, // Edit individual block level elements editElement: function() { // Do funky stuff to get around a Firefox caret browsing bug. if (Mimulus.doc.mimulusEditElement) { Mimulus.editElementOff(true); } else { Mimulus.editElementOn(); } }, editElementOn: function() { // @@ Preserve attributes! var e = Mimulus.findParent(['p', 'pre', 'ul']); // Unique ID that nobody else in the world will use var uid = 'uidemarsdendownloads'; var textarea = Mimulus.doc.getElementById('mimulus-' + uid); if (!e) { Mimulus.notice('This only works in

,

, and