]> git.donarmstrong.com Git - dactyl.git/blobdiff - common/content/editor.js
Import 1.0rc1 supporting Firefox up to 11.*
[dactyl.git] / common / content / editor.js
index d2fe3224b9f063b010fe42eae926f1418a41df07..262391ece5c0696b42388e4cc65c8082146a2764 100644 (file)
@@ -3,7 +3,7 @@
 //
 // This work is licensed for reuse under an MIT license. Details are
 // given in the LICENSE.txt file included with this file.
-"use strict";
+/* use strict */
 
 /** @scope modules */
 
 // http://developer.mozilla.org/en/docs/Editor_Embedding_Guide
 
 /** @instance editor */
-var Editor = Module("editor", {
+var Editor = Module("editor", XPCOM(Ci.nsIEditActionListener, ModuleBase), {
+    init: function init(elem) {
+        if (elem)
+            this.element = elem;
+        else
+            this.__defineGetter__("element", function () {
+                let elem = dactyl.focusedElement;
+                if (elem)
+                    return elem.inputField || elem;
+
+                let win = document.commandDispatcher.focusedWindow;
+                return DOM(win).isEditable && win || null;
+            });
+    },
+
+    get registers() storage.newMap("registers", { privateData: true, store: true }),
+    get registerRing() storage.newArray("register-ring", { privateData: true, store: true }),
+
+    skipSave: false,
+
+    // Fixme: Move off this object.
+    currentRegister: null,
+
+    /**
+     * Temporarily set the default register for the span of the next
+     * mapping.
+     */
+    pushRegister: function pushRegister(arg) {
+        let restore = this.currentRegister;
+        this.currentRegister = arg;
+        mappings.afterCommands(2, function () {
+            this.currentRegister = restore;
+        }, this);
+    },
+
+    defaultRegister: "*",
+
+    selectionRegisters: {
+        "*": "selection",
+        "+": "global"
+    },
+
+    /**
+     * Get the value of the register *name*.
+     *
+     * @param {string|number} name The name of the register to get.
+     * @returns {string|null}
+     * @see #setRegister
+     */
+    getRegister: function getRegister(name) {
+        if (name == null)
+            name = editor.currentRegister || editor.defaultRegister;
+
+        if (name == '"')
+            name = 0;
+        if (name == "_")
+            var res = null;
+        else if (Set.has(this.selectionRegisters, name))
+            res = { text: dactyl.clipboardRead(this.selectionRegisters[name]) || "" };
+        else if (!/^[0-9]$/.test(name))
+            res = this.registers.get(name);
+        else
+            res = this.registerRing.get(name);
+
+        return res != null ? res.text : res;
+    },
+
+    /**
+     * Sets the value of register *name* to value. The following
+     * registers have special semantics:
+     *
+     *   *   - Tied to the PRIMARY selection value on X11 systems.
+     *   +   - Tied to the primary global clipboard.
+     *   _   - The null register. Never has any value.
+     *   "   - Equivalent to 0.
+     *   0-9 - These act as a kill ring. Setting any of them pushes the
+     *         values of higher numbered registers up one slot.
+     *
+     * @param {string|number} name The name of the register to set.
+     * @param {string|Range|Selection|Node} value The value to save to
+     *      the register.
+     */
+    setRegister: function setRegister(name, value, verbose) {
+        if (name == null)
+            name = editor.currentRegister || editor.defaultRegister;
+
+        if (isinstance(value, [Ci.nsIDOMRange, Ci.nsIDOMNode, Ci.nsISelection]))
+            value = DOM.stringify(value);
+        value = { text: value, isLine: modes.extended & modes.LINE, timestamp: Date.now() * 1000 };
+
+        if (name == '"')
+            name = 0;
+        if (name == "_")
+            ;
+        else if (Set.has(this.selectionRegisters, name))
+            dactyl.clipboardWrite(value.text, verbose, this.selectionRegisters[name]);
+        else if (!/^[0-9]$/.test(name))
+            this.registers.set(name, value);
+        else {
+            this.registerRing.insert(value, name);
+            this.registerRing.truncate(10);
+        }
+    },
+
     get isCaret() modes.getStack(1).main == modes.CARET,
     get isTextEdit() modes.getStack(1).main == modes.TEXT_EDIT,
 
-    unselectText: function (toEnd) {
-        try {
-            Editor.getEditor(null).selection[toEnd ? "collapseToEnd" : "collapseToStart"]();
+    get editor() DOM(this.element).editor,
+
+    getController: function getController(cmd) {
+        let controllers = this.element && this.element.controllers;
+        dactyl.assert(controllers);
+
+        return controllers.getControllerForCommand(cmd || "cmd_beginLine");
+    },
+
+    get selection() this.editor && this.editor.selection || null,
+    get selectionController() this.editor && this.editor.selectionController || null,
+
+    deselect: function () {
+        if (this.selection && this.selection.focusNode)
+            this.selection.collapse(this.selection.focusNode,
+                                    this.selection.focusOffset);
+    },
+
+    get selectedRange() {
+        if (!this.selection)
+            return null;
+
+        if (!this.selection.rangeCount) {
+            let range = RangeFind.nodeContents(this.editor.rootElement.ownerDocument);
+            range.collapse(true);
+            this.selectedRange = range;
         }
-        catch (e) {}
+        return this.selection.getRangeAt(0);
+    },
+    set selectedRange(range) {
+        this.selection.removeAllRanges();
+        if (range != null)
+            this.selection.addRange(range);
     },
 
-    selectedText: function () String(Editor.getEditor(null).selection),
+    get selectedText() String(this.selection),
 
-    pasteClipboard: function (clipboard, toStart) {
-        let elem = dactyl.focusedElement;
-        if (elem.inputField)
-            elem = elem.inputField;
+    get preserveSelection() this.editor && !this.editor.shouldTxnSetSelection,
+    set preserveSelection(val) {
+        if (this.editor)
+            this.editor.setShouldTxnSetSelection(!val);
+    },
 
-        if (elem.setSelectionRange) {
-            let text = dactyl.clipboardRead(clipboard);
-            if (!text)
-                return;
-            if (isinstance(elem, [HTMLInputElement, XULTextBoxElement]))
-                text = text.replace(/\n+/g, "");
+    copy: function copy(range, name) {
+        range = range || this.selection;
 
-            // This is a hacky fix - but it works.
-            // <s-insert> in the bottom of a long textarea bounces up
-            let top = elem.scrollTop;
-            let left = elem.scrollLeft;
+        if (!range.collapsed)
+            this.setRegister(name, range);
+    },
 
-            let start = elem.selectionStart; // caret position
-            let end = elem.selectionEnd;
-            let value = elem.value.substring(0, start) + text + elem.value.substring(end);
-            elem.value = value;
+    cut: function cut(range, name) {
+        if (range)
+            this.selectedRange = range;
 
-            if (/^(search|text)$/.test(elem.type))
-                Editor.getEditor(elem).rootElement.firstChild.textContent = value;
+        if (!this.selection.isCollapsed)
+            this.setRegister(name, this.selection);
 
-            elem.selectionStart = Math.min(start + (toStart ? 0 : text.length), elem.value.length);
-            elem.selectionEnd = elem.selectionStart;
+        this.editor.deleteSelection(0);
+    },
 
-            elem.scrollTop = top;
-            elem.scrollLeft = left;
+    paste: function paste(name) {
+        let text = this.getRegister(name);
+        dactyl.assert(text && this.editor instanceof Ci.nsIPlaintextEditor);
 
-            events.dispatch(elem, events.create(elem.ownerDocument, "input"));
-        }
+        this.editor.insertText(text);
     },
 
     // count is optional, defaults to 1
-    executeCommand: function (cmd, count) {
-        let editor = Editor.getEditor(null);
-        let controller = Editor.getController();
-        dactyl.assert(callable(cmd) ||
-                          controller &&
-                          controller.supportsCommand(cmd) &&
-                          controller.isCommandEnabled(cmd));
+    executeCommand: function executeCommand(cmd, count) {
+        if (!callable(cmd)) {
+            var controller = this.getController(cmd);
+            util.assert(controller &&
+                        controller.supportsCommand(cmd) &&
+                        controller.isCommandEnabled(cmd));
+            cmd = bind("doCommand", controller, cmd);
+        }
 
         // XXX: better as a precondition
         if (count == null)
-          count = 1;
+            count = 1;
 
         let didCommand = false;
         while (count--) {
             // some commands need this try/catch workaround, because a cmd_charPrevious triggered
             // at the beginning of the textarea, would hang the doCommand()
             // good thing is, we need this code anyway for proper beeping
+
+            // What huh? --Kris
             try {
-                if (callable(cmd))
-                    cmd(editor, controller);
-                else
-                    controller.doCommand(cmd);
+                cmd(this.editor, controller);
                 didCommand = true;
             }
             catch (e) {
@@ -92,133 +218,135 @@ var Editor = Module("editor", {
         }
     },
 
-    // cmd = y, d, c
-    // motion = b, 0, gg, G, etc.
-    selectMotion: function selectMotion(cmd, motion, count) {
-        // XXX: better as a precondition
-        if (count == null)
-            count = 1;
+    moveToPosition: function (pos, select) {
+        if (isObject(pos))
+            var { startContainer, startOffset } = pos;
+        else
+            [startOffset, startOffset] = [this.selection.focusNode, pos];
+        this.selection[select ? "extend" : "collapse"](startContainer, startOffset);
+    },
 
-        if (cmd == motion) {
-            motion = "j";
-            count--;
-        }
+    mungeRange: function mungeRange(range, munger, selectEnd) {
+        let { editor } = this;
+        editor.beginPlaceHolderTransaction(null);
 
-        if (modes.main != modes.VISUAL)
-            modes.push(modes.VISUAL);
-
-        switch (motion) {
-        case "j":
-            this.executeCommand("cmd_beginLine", 1);
-            this.executeCommand("cmd_selectLineNext", count + 1);
-            break;
-        case "k":
-            this.executeCommand("cmd_beginLine", 1);
-            this.executeCommand("cmd_lineNext", 1);
-            this.executeCommand("cmd_selectLinePrevious", count + 1);
-            break;
-        case "h":
-            this.executeCommand("cmd_selectCharPrevious", count);
-            break;
-        case "l":
-            this.executeCommand("cmd_selectCharNext", count);
-            break;
-        case "e":
-        case "w":
-            this.executeCommand("cmd_selectWordNext", count);
-            break;
-        case "b":
-            this.executeCommand("cmd_selectWordPrevious", count);
-            break;
-        case "0":
-        case "^":
-            this.executeCommand("cmd_selectBeginLine", 1);
-            break;
-        case "$":
-            this.executeCommand("cmd_selectEndLine", 1);
-            break;
-        case "gg":
-            this.executeCommand("cmd_endLine", 1);
-            this.executeCommand("cmd_selectTop", 1);
-            this.executeCommand("cmd_selectBeginLine", 1);
-            break;
-        case "G":
-            this.executeCommand("cmd_beginLine", 1);
-            this.executeCommand("cmd_selectBottom", 1);
-            this.executeCommand("cmd_selectEndLine", 1);
-            break;
-
-        default:
-            dactyl.beep();
-            return;
-        }
-    },
+        let [container, offset] = ["startContainer", "startOffset"];
+        if (selectEnd)
+            [container, offset] = ["endContainer", "endOffset"];
 
-    // This function will move/select up to given "pos"
-    // Simple setSelectionRange() would be better, but we want to maintain the correct
-    // order of selectionStart/End (a Gecko bug always makes selectionStart <= selectionEnd)
-    // Use only for small movements!
-    moveToPosition: function (pos, forward, select) {
-        if (!select) {
-            Editor.getEditor().setSelectionRange(pos, pos);
-            return;
-        }
+        try {
+            // :(
+            let idx = range[offset];
+            let parent = range[container].parentNode;
+            let parentIdx = Array.indexOf(parent.childNodes,
+                                          range[container]);
+
+            let delta = 0;
+            for (let node in Editor.TextsIterator(range)) {
+                let text = node.textContent;
+                let start = 0, end = text.length;
+                if (node == range.startContainer)
+                    start = range.startOffset;
+                if (node == range.endContainer)
+                    end = range.endOffset;
+
+                if (start == 0 && end == text.length)
+                    text = munger(text);
+                else
+                    text = text.slice(0, start)
+                         + munger(text.slice(start, end))
+                         + text.slice(end);
 
-        if (forward) {
-            if (pos <= Editor.getEditor().selectionEnd || pos > Editor.getEditor().value.length)
-                return;
+                if (text == node.textContent)
+                    continue;
 
-            do { // TODO: test code for endless loops
-                this.executeCommand("cmd_selectCharNext", 1);
-            }
-            while (Editor.getEditor().selectionEnd != pos);
-        }
-        else {
-            if (pos >= Editor.getEditor().selectionStart || pos < 0)
-                return;
+                if (selectEnd)
+                    delta = text.length - node.textContent.length;
 
-            do { // TODO: test code for endless loops
-                this.executeCommand("cmd_selectCharPrevious", 1);
+                if (editor instanceof Ci.nsIPlaintextEditor) {
+                    this.selectedRange = RangeFind.nodeContents(node);
+                    editor.insertText(text);
+                }
+                else
+                    node.textContent = text;
             }
-            while (Editor.getEditor().selectionStart != pos);
+            let node = parent.childNodes[parentIdx];
+            if (node instanceof Text)
+                idx = Math.constrain(idx + delta, 0, node.textContent.length);
+            this.selection.collapse(node, idx);
+        }
+        finally {
+            editor.endPlaceHolderTransaction();
         }
     },
 
-    findChar: function (key, count, backward) {
+    findChar: function findNumber(key, count, backward, offset) {
+        count  = count || 1; // XXX ?
+        offset = (offset || 0) - !!backward;
 
-        let editor = Editor.getEditor();
-        if (!editor)
-            return -1;
+        // Grab the charcode of the key spec. Using the key name
+        // directly will break keys like <
+        let code = DOM.Event.parse(key)[0].charCode;
+        let char = String.fromCharCode(code);
+        util.assert(code);
 
-        // XXX
-        if (count == null)
-            count = 1;
+        let range = this.selectedRange.cloneRange();
+        let collapse = DOM(this.element).whiteSpace == "normal";
 
-        let code = events.fromString(key)[0].charCode;
-        util.assert(code);
-        let char = String.fromCharCode(code);
+        // Find the *count*th occurance of *char* before a non-collapsed
+        // \n, ignoring the character at the caret.
+        let i = 0;
+        function test(c) (collapse || c != "\n") && !!(!i++ || c != char || --count)
 
-        let text = editor.value;
-        let caret = editor.selectionEnd;
-        if (backward) {
-            let end = text.lastIndexOf("\n", caret);
-            while (caret > end && caret >= 0 && count--)
-                caret = text.lastIndexOf(char, caret - 1);
-        }
-        else {
-            let end = text.indexOf("\n", caret);
-            if (end == -1)
-                end = text.length;
+        Editor.extendRange(range, !backward, { test: test }, true);
+        dactyl.assert(count == 0);
+        range.collapse(backward);
+
+        // Skip to any requested offset.
+        count = Math.abs(offset);
+        Editor.extendRange(range, offset > 0, { test: function (c) !!count-- }, true);
+        range.collapse(offset < 0);
+
+        return range;
+    },
+
+    findNumber: function findNumber(range) {
+        if (!range)
+            range = this.selectedRange.cloneRange();
 
-            while (caret < end && caret >= 0 && count--)
-                caret = text.indexOf(char, caret + 1);
+        // Find digit (or \n).
+        Editor.extendRange(range, true, /[^\n\d]/, true);
+        range.collapse(false);
+        // Select entire number.
+        Editor.extendRange(range, true, /\d/, true);
+        Editor.extendRange(range, false, /\d/, true);
+
+        // Sanity check.
+        dactyl.assert(/^\d+$/.test(range));
+
+        if (false) // Skip for now.
+        if (range.startContainer instanceof Text && range.startOffset > 2) {
+            if (range.startContainer.textContent.substr(range.startOffset - 2, 2) == "0x")
+                range.setStart(range.startContainer, range.startOffset - 2);
         }
 
-        if (count > 0)
-            caret = -1;
-        if (caret == -1)
-            dactyl.beep();
-        return caret;
+        // Grab the sign, if it's there.
+        Editor.extendRange(range, false, /[+-]/, true);
+
+        return range;
+    },
+
+    modifyNumber: function modifyNumber(delta, range) {
+        range = this.findNumber(range);
+        let number = parseInt(range) + delta;
+        if (/^[+-]?0x/.test(range))
+            number = number.toString(16).replace(/^[+-]?/, "$&0x");
+        else if (/^[+-]?0\d/.test(range))
+            number = number.toString(8).replace(/^[+-]?/, "$&0");
+
+        this.selectedRange = range;
+        this.editor.insertText(String(number));
+        this.selection.modify("move", "backward", "character");
     },
 
     /**
@@ -249,7 +377,11 @@ var Editor = Module("editor", {
             return;
 
         let textBox = config.isComposeWindow ? null : dactyl.focusedElement;
+        if (!DOM(textBox).isInput)
+            textBox = null;
+
         let line, column;
+        let keepFocus = modes.stack.some(function (m) isinstance(m.main, modes.COMMAND_LINE));
 
         if (!forceEditing && textBox && textBox.type == "password") {
             commandline.input(_("editor.prompt.editPassword") + " ",
@@ -262,18 +394,32 @@ var Editor = Module("editor", {
 
         if (textBox) {
             var text = textBox.value;
-            let pre = text.substr(0, textBox.selectionStart);
-            line = 1 + pre.replace(/[^\n]/g, "").length;
-            column = 1 + pre.replace(/[^]*\n/, "").length;
+            var pre = text.substr(0, textBox.selectionStart);
         }
         else {
             var editor_ = window.GetCurrentEditor ? GetCurrentEditor()
                                                   : Editor.getEditor(document.commandDispatcher.focusedWindow);
             dactyl.assert(editor_);
-            text = Array.map(editor_.rootElement.childNodes, function (e) util.domToString(e, true)).join("");
+            text = Array.map(editor_.rootElement.childNodes, function (e) DOM.stringify(e, true)).join("");
+
+            if (!editor_.selection.rangeCount)
+                var sel = "";
+            else {
+                let range = RangeFind.nodeContents(editor_.rootElement);
+                let end = editor_.selection.getRangeAt(0);
+                range.setEnd(end.startContainer, end.startOffset);
+                pre = DOM.stringify(range, true);
+                if (range.startContainer instanceof Text)
+                    pre = pre.replace(/^(?:<[^>"]+>)+/, "");
+                if (range.endContainer instanceof Text)
+                    pre = pre.replace(/(?:<\/[^>"]+>)+$/, "");
+            }
         }
 
-        let origGroup = textBox && textBox.getAttributeNS(NS, "highlight") || "";
+        line = 1 + pre.replace(/[^\n]/g, "").length;
+        column = 1 + pre.replace(/[^]*\n/, "").length;
+
+        let origGroup = DOM(textBox).highlight.toString();
         let cleanup = util.yieldable(function cleanup(error) {
             if (timer)
                 timer.cancel();
@@ -290,7 +436,9 @@ var Editor = Module("editor", {
                 tmpfile.remove(false);
 
             if (textBox) {
-                dactyl.focus(textBox);
+                DOM(textBox).highlight.remove("EditorEditing");
+                if (!keepFocus)
+                    dactyl.focus(textBox);
                 for (let group in values(blink.concat(blink, ""))) {
                     highlight.highlightNode(textBox, origGroup + " " + group);
                     yield 100;
@@ -307,10 +455,12 @@ var Editor = Module("editor", {
             if (textBox) {
                 textBox.value = val;
 
-                textBox.setAttributeNS(NS, "modifiable", true);
-                util.computedStyle(textBox).MozUserInput;
-                events.dispatch(textBox, events.create(textBox.ownerDocument, "input", {}));
-                textBox.removeAttributeNS(NS, "modifiable");
+                if (false) {
+                    let elem = DOM(textBox);
+                    elem.attrNS(NS, "modifiable", true)
+                        .style.MozUserInput;
+                    elem.input().attrNS(NS, "modifiable", null);
+                }
             }
             else {
                 while (editor_.rootElement.firstChild)
@@ -325,8 +475,9 @@ var Editor = Module("editor", {
                 throw Error(_("io.cantCreateTempFile"));
 
             if (textBox) {
-                highlight.highlightNode(textBox, origGroup + " EditorEditing");
-                textBox.blur();
+                if (!keepFocus)
+                    textBox.blur();
+                DOM(textBox).highlight.add("EditorEditing");
             }
 
             if (!tmpfile.write(text))
@@ -349,38 +500,167 @@ var Editor = Module("editor", {
      * @see Abbreviation#expand
      */
     expandAbbreviation: function (mode) {
-        let elem = dactyl.focusedElement;
-        if (!(elem && elem.value))
+        if (!this.selection)
             return;
 
-        let text   = elem.value;
-        let start  = elem.selectionStart;
-        let end    = elem.selectionEnd;
-        let abbrev = abbreviations.match(mode, text.substring(0, start).replace(/.*\s/g, ""));
+        let range = this.selectedRange.cloneRange();
+        if (!range.collapsed)
+            return;
+
+        Editor.extendRange(range, false, /\S/, true);
+        let abbrev = abbreviations.match(mode, String(range));
         if (abbrev) {
-            let len = abbrev.lhs.length;
-            let rhs = abbrev.expand(elem);
-            elem.value = text.substring(0, start - len) + rhs + text.substring(start);
-            elem.selectionStart = start - len + rhs.length;
-            elem.selectionEnd   = end   - len + rhs.length;
+            range.setStart(range.startContainer, range.endOffset - abbrev.lhs.length);
+            this.selectedRange = range;
+            this.editor.insertText(abbrev.expand(this.element));
         }
     },
+
+    // nsIEditActionListener:
+    WillDeleteNode: util.wrapCallback(function WillDeleteNode(node) {
+        if (!editor.skipSave && node.textContent)
+            this.setRegister(0, node);
+    }),
+    WillDeleteSelection: util.wrapCallback(function WillDeleteSelection(selection) {
+        if (!editor.skipSave && !selection.isCollapsed)
+            this.setRegister(0, selection);
+    }),
+    WillDeleteText: util.wrapCallback(function WillDeleteText(node, start, length) {
+        if (!editor.skipSave && length)
+            this.setRegister(0, node.textContent.substr(start, length));
+    })
 }, {
-    extendRange: function extendRange(range, forward, re, sameWord) {
+    TextsIterator: Class("TextsIterator", {
+        init: function init(range, context, after) {
+            this.after = after;
+            this.start = context || range[after ? "endContainer" : "startContainer"];
+            if (after)
+                this.context = this.start;
+            this.range = range;
+        },
+
+        __iterator__: function __iterator__() {
+            while (this.nextNode())
+                yield this.context;
+        },
+
+        prevNode: function prevNode() {
+            if (!this.context)
+                return this.context = this.start;
+
+            var node = this.context;
+            if (!this.after)
+                node = node.previousSibling;
+
+            if (!node)
+                node = this.context.parentNode;
+            else
+                while (node.lastChild)
+                    node = node.lastChild;
+
+            if (!node || !RangeFind.containsNode(this.range, node, true))
+                return null;
+            this.after = false;
+            return this.context = node;
+        },
+
+        nextNode: function nextNode() {
+            if (!this.context)
+                return this.context = this.start;
+
+            if (!this.after)
+                var node = this.context.firstChild;
+
+            if (!node) {
+                node = this.context;
+                while (node.parentNode && node != this.range.endContainer
+                        && !node.nextSibling)
+                    node = node.parentNode;
+
+                node = node.nextSibling;
+            }
+
+            if (!node || !RangeFind.containsNode(this.range, node, true))
+                return null;
+            this.after = false;
+            return this.context = node;
+        },
+
+        getPrev: function getPrev() {
+            return this.filter("prevNode");
+        },
+
+        getNext: function getNext() {
+            return this.filter("nextNode");
+        },
+
+        filter: function filter(meth) {
+            let node;
+            while (node = this[meth]())
+                if (node instanceof Ci.nsIDOMText &&
+                        DOM(node).isVisible &&
+                        DOM(node).style.MozUserSelect != "none")
+                    return node;
+        }
+    }),
+
+    extendRange: function extendRange(range, forward, re, sameWord, root, end) {
         function advance(positive) {
-            let idx = range.endOffset;
-            while (idx < text.length && re.test(text[idx++]) == positive)
-                range.setEnd(range.endContainer, idx);
+            while (true) {
+                while (idx == text.length && (node = iterator.getNext())) {
+                    if (node == iterator.start)
+                        idx = range[offset];
+
+                    start = text.length;
+                    text += node.textContent;
+                    range[set](node, idx - start);
+                }
+
+                if (idx >= text.length || re.test(text[idx]) != positive)
+                    break;
+                range[set](range[container], ++idx - start);
+            }
         }
         function retreat(positive) {
-            let idx = range.startOffset;
-            while (idx > 0 && re.test(text[--idx]) == positive)
-                range.setStart(range.startContainer, idx);
+            while (true) {
+                while (idx == 0 && (node = iterator.getPrev())) {
+                    let str = node.textContent;
+                    if (node == iterator.start)
+                        idx = range[offset];
+                    else
+                        idx = str.length;
+
+                    text = str + text;
+                    range[set](node, idx);
+                }
+                if (idx == 0 || re.test(text[idx - 1]) != positive)
+                    break;
+                range[set](range[container], --idx);
+            }
         }
 
-        let nodeRange = range.cloneRange();
-        nodeRange.selectNodeContents(range.startContainer);
-        let text = String(nodeRange);
+        if (end == null)
+            end = forward ? "end" : "start";
+        let [container, offset, set] = [end + "Container", end + "Offset",
+                                        "set" + util.capitalize(end)];
+
+        if (!root)
+            for (root = range[container];
+                 root.parentNode instanceof Element && !DOM(root).isEditable;
+                 root = root.parentNode)
+                ;
+        if (root instanceof Ci.nsIDOMNSEditableElement)
+            root = root.editor;
+        if (root instanceof Ci.nsIEditor)
+            root = root.rootElement;
+
+        let node = range[container];
+        let iterator = Editor.TextsIterator(RangeFind.nodeContents(root),
+                                            node, !forward);
+
+        let text = "";
+        let idx  = 0;
+        let start = 0;
 
         if (forward) {
             advance(true);
@@ -405,93 +685,181 @@ var Editor = Module("editor", {
             elem = dactyl.focusedElement || document.commandDispatcher.focusedWindow;
         dactyl.assert(elem);
 
-        try {
-            if (elem instanceof Element)
-                return elem.QueryInterface(Ci.nsIDOMNSEditableElement).editor;
-            return elem.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation)
-                       .QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIEditingSession)
-                       .getEditorForWindow(elem);
-        }
-        catch (e) {
-            return null;
-        }
+        return DOM(elem).editor;
+    }
+}, {
+    modes: function init_modes() {
+        modes.addMode("OPERATOR", {
+            char: "o",
+            description: "Mappings which move the cursor",
+            bases: []
+        });
+        modes.addMode("VISUAL", {
+            char: "v",
+            description: "Active when text is selected",
+            display: function () "VISUAL" + (this._extended & modes.LINE ? " LINE" : ""),
+            bases: [modes.COMMAND],
+            ownsFocus: true
+        }, {
+            enter: function (stack) {
+                if (editor.selectionController)
+                    editor.selectionController.setCaretVisibilityDuringSelection(true);
+            },
+            leave: function (stack, newMode) {
+                if (newMode.main == modes.CARET) {
+                    let selection = content.getSelection();
+                    if (selection && !selection.isCollapsed)
+                        selection.collapseToStart();
+                }
+                else if (stack.pop)
+                    editor.deselect();
+            }
+        });
+        modes.addMode("TEXT_EDIT", {
+            char: "t",
+            description: "Vim-like editing of input elements",
+            bases: [modes.COMMAND],
+            ownsFocus: true
+        }, {
+            onKeyPress: function (eventList) {
+                const KILL = false, PASS = true;
+
+                // Hack, really.
+                if (eventList[0].charCode || /^<(?:.-)*(?:BS|Del|C-h|C-w|C-u|C-k)>$/.test(DOM.Event.stringify(eventList[0]))) {
+                    dactyl.beep();
+                    return KILL;
+                }
+                return PASS;
+            }
+        });
+
+        modes.addMode("INSERT", {
+            char: "i",
+            description: "Active when an input element is focused",
+            insert: true,
+            ownsFocus: true
+        });
+        modes.addMode("AUTOCOMPLETE", {
+            description: "Active when an input autocomplete pop-up is active",
+            display: function () "AUTOCOMPLETE (insert)",
+            bases: [modes.INSERT]
+        });
+    },
+    commands: function init_commands() {
+        commands.add(["reg[isters]"],
+            "List the contents of known registers",
+            function (args) {
+                completion.listCompleter("register", args[0]);
+            },
+            { argCount: "*" });
     },
+    completion: function init_completion() {
+        completion.register = function complete_register(context) {
+            context = context.fork("registers");
+            context.keys = { text: util.identity, description: editor.closure.getRegister };
 
-    getController: function () {
-        let ed = dactyl.focusedElement;
-        if (!ed || !ed.controllers)
-            return null;
+            context.match = function (r) !this.filter || ~this.filter.indexOf(r);
 
-        return ed.controllers.getControllerForCommand("cmd_beginLine");
-    }
-}, {
-    mappings: function () {
+            context.fork("clipboard", 0, this, function (ctxt) {
+                ctxt.match = context.match;
+                ctxt.title = ["Clipboard Registers"];
+                ctxt.completions = Object.keys(editor.selectionRegisters);
+            });
+            context.fork("kill-ring", 0, this, function (ctxt) {
+                ctxt.match = context.match;
+                ctxt.title = ["Kill Ring Registers"];
+                ctxt.completions = Array.slice("0123456789");
+            });
+            context.fork("user", 0, this, function (ctxt) {
+                ctxt.match = context.match;
+                ctxt.title = ["User Defined Registers"];
+                ctxt.completions = editor.registers.keys();
+            });
+        };
+    },
+    mappings: function init_mappings() {
 
-        // add mappings for commands like h,j,k,l,etc. in CARET, VISUAL and TEXT_EDIT mode
-        function addMovementMap(keys, description, hasCount, caretModeMethod, caretModeArg, textEditCommand, visualTextEditCommand) {
-            let extraInfo = {};
-            if (hasCount)
-                extraInfo.count = true;
-
-            function caretExecute(arg, again) {
-                function fixSelection() {
-                    sel.removeAllRanges();
-                    sel.addRange(RangeFind.endpoint(
-                        RangeFind.nodeRange(buffer.focusedFrame.document.documentElement),
-                        true));
+        Map.types["editor"] = {
+            preExecute: function preExecute(args) {
+                if (editor.editor && !this.editor) {
+                    this.editor = editor.editor;
+                    this.editor.beginTransaction();
+                }
+                editor.inEditMap = true;
+            },
+            postExecute: function preExecute(args) {
+                editor.inEditMap = false;
+                if (this.editor) {
+                    this.editor.endTransaction();
+                    this.editor = null;
                 }
+            },
+        };
+        Map.types["operator"] = {
+            preExecute: function preExecute(args) {
+                editor.inEditMap = true;
+            },
+            postExecute: function preExecute(args) {
+                editor.inEditMap = true;
+                if (modes.main == modes.OPERATOR)
+                    modes.pop();
+            }
+        };
 
-                let controller = buffer.selectionController;
+        // add mappings for commands like h,j,k,l,etc. in CARET, VISUAL and TEXT_EDIT mode
+        function addMovementMap(keys, description, hasCount, caretModeMethod, caretModeArg, textEditCommand, visualTextEditCommand) {
+            let extraInfo = {
+                count: !!hasCount,
+                type: "operator"
+            };
+
+            function caretExecute(arg) {
+                let win = document.commandDispatcher.focusedWindow;
+                let controller = util.selectionController(win);
                 let sel = controller.getSelection(controller.SELECTION_NORMAL);
+
+                let buffer = Buffer(win);
                 if (!sel.rangeCount) // Hack.
-                    fixSelection();
+                    buffer.resetCaret();
 
-                try {
-                    controller[caretModeMethod](caretModeArg, arg);
-                }
-                catch (e) {
-                    dactyl.assert(again && e.result === Cr.NS_ERROR_FAILURE);
-                    fixSelection();
-                    caretExecute(arg, false);
+                if (caretModeMethod == "pageMove") { // Grr.
+                    buffer.scrollVertical("pages", caretModeArg ? 1 : -1);
+                    buffer.resetCaret();
                 }
+                else
+                    controller[caretModeMethod](caretModeArg, arg);
             }
 
-            mappings.add([modes.CARET], keys, description,
-                function ({ count }) {
-                    if (!count)
-                       count = 1;
-
-                    while (count--)
-                        caretExecute(false, true);
-                },
-                extraInfo);
-
             mappings.add([modes.VISUAL], keys, description,
                 function ({ count }) {
-                    if (!count)
-                        count = 1;
+                    count = count || 1;
 
-                    let editor_ = Editor.getEditor(null);
+                    let caret = !dactyl.focusedElement;
                     let controller = buffer.selectionController;
+
                     while (count-- && modes.main == modes.VISUAL) {
-                        if (editor.isTextEdit) {
+                        if (caret)
+                            caretExecute(true, true);
+                        else {
                             if (callable(visualTextEditCommand))
-                                visualTextEditCommand(editor_);
+                                visualTextEditCommand(editor.editor);
                             else
                                 editor.executeCommand(visualTextEditCommand);
                         }
-                        else
-                            caretExecute(true, true);
                     }
                 },
                 extraInfo);
 
-            mappings.add([modes.TEXT_EDIT], keys, description,
+            mappings.add([modes.CARET, modes.TEXT_EDIT, modes.OPERATOR], keys, description,
                 function ({ count }) {
-                    if (!count)
-                        count = 1;
+                    count = count || 1;
 
-                    editor.executeCommand(textEditCommand, count);
+                    if (editor.editor)
+                        editor.executeCommand(textEditCommand, count);
+                    else {
+                        while (count--)
+                            caretExecute(false);
+                    }
                 },
                 extraInfo);
         }
@@ -500,42 +868,62 @@ var Editor = Module("editor", {
         function addBeginInsertModeMap(keys, commands, description) {
             mappings.add([modes.TEXT_EDIT], keys, description || "",
                 function () {
-                    commands.forEach(function (cmd)
-                        editor.executeCommand(cmd, 1));
+                    commands.forEach(function (cmd) { editor.executeCommand(cmd, 1) });
                     modes.push(modes.INSERT);
-                });
+                },
+                { type: "editor" });
         }
 
         function selectPreviousLine() {
             editor.executeCommand("cmd_selectLinePrevious");
-            if ((modes.extended & modes.LINE) && !editor.selectedText())
+            if ((modes.extended & modes.LINE) && !editor.selectedText)
                 editor.executeCommand("cmd_selectLinePrevious");
         }
 
         function selectNextLine() {
             editor.executeCommand("cmd_selectLineNext");
-            if ((modes.extended & modes.LINE) && !editor.selectedText())
+            if ((modes.extended & modes.LINE) && !editor.selectedText)
                 editor.executeCommand("cmd_selectLineNext");
         }
 
-        function updateRange(editor, forward, re, modify) {
-            let range = Editor.extendRange(editor.selection.getRangeAt(0),
-                                           forward, re, false);
+        function updateRange(editor, forward, re, modify, sameWord) {
+            let sel   = editor.selection;
+            let range = sel.getRangeAt(0);
+
+            let end = range.endContainer == sel.focusNode && range.endOffset == sel.focusOffset;
+            if (range.collapsed)
+                end = forward;
+
+            Editor.extendRange(range, forward, re, sameWord,
+                               editor.rootElement, end ? "end" : "start");
             modify(range);
-            editor.selection.removeAllRanges();
-            editor.selection.addRange(range);
+            editor.selectionController.repaintSelection(editor.selectionController.SELECTION_NORMAL);
         }
-        function move(forward, re)
+
+        function clear(forward, re)
+            function _clear(editor) {
+                updateRange(editor, forward, re, function (range) {});
+                dactyl.assert(!editor.selection.isCollapsed);
+                editor.selection.deleteFromDocument();
+                let parent = DOM(editor.rootElement.parentNode);
+                if (parent.isInput)
+                    parent.input();
+            }
+
+        function move(forward, re, sameWord)
             function _move(editor) {
-                updateRange(editor, forward, re, function (range) { range.collapse(!forward); });
+                updateRange(editor, forward, re,
+                            function (range) { range.collapse(!forward); },
+                            sameWord);
             }
         function select(forward, re)
             function _select(editor) {
-                updateRange(editor, forward, re, function (range) {});
+                updateRange(editor, forward, re,
+                            function (range) {});
             }
         function beginLine(editor_) {
             editor.executeCommand("cmd_beginLine");
-            move(true, /\S/)(editor_);
+            move(true, /\s/, true)(editor_);
         }
 
         //             COUNT  CARET                   TEXT_EDIT            VISUAL_TEXT_EDIT
@@ -548,9 +936,9 @@ var Editor = Module("editor", {
         addMovementMap(["l", "<Right>", "<Space>"],   "Move right one character",
                        true,  "characterMove", true,  "cmd_charNext",     "cmd_selectCharNext");
         addMovementMap(["b", "<C-Left>"],             "Move left one word",
-                       true,  "wordMove", false,      "cmd_wordPrevious", "cmd_selectWordPrevious");
+                       true,  "wordMove", false,      move(false,  /\w/), select(false, /\w/));
         addMovementMap(["w", "<C-Right>"],            "Move right one word",
-                       true,  "wordMove", true,       "cmd_wordNext",     "cmd_selectWordNext");
+                       true,  "wordMove", true,       move(true,  /\w/),  select(true, /\w/));
         addMovementMap(["B"],                         "Move left to the previous white space",
                        true,  "wordMove", false,      move(false, /\S/),  select(false, /\S/));
         addMovementMap(["W"],                         "Move right to just beyond the next white space",
@@ -582,77 +970,158 @@ var Editor = Module("editor", {
         addBeginInsertModeMap(["S"],             ["cmd_deleteToEndOfLine", "cmd_deleteToBeginningOfLine"], "Delete the current line and start insert");
         addBeginInsertModeMap(["C"],             ["cmd_deleteToEndOfLine"], "Delete from the cursor to the end of the line and start insert");
 
-        function addMotionMap(key, desc, cmd, mode) {
-            mappings.add([modes.TEXT_EDIT], [key],
+        function addMotionMap(key, desc, select, cmd, mode, caretOk) {
+            function doTxn(range, editor) {
+                try {
+                    editor.editor.beginTransaction();
+                    cmd(editor, range, editor.editor);
+                }
+                finally {
+                    editor.editor.endTransaction();
+                }
+            }
+
+            mappings.add([modes.TEXT_EDIT], key,
+                desc,
+                function ({ command, count, motion }) {
+                    let start = editor.selectedRange.cloneRange();
+
+                    mappings.pushCommand();
+                    modes.push(modes.OPERATOR, null, {
+                        forCommand: command,
+
+                        count: count,
+
+                        leave: function leave(stack) {
+                            try {
+                                if (stack.push || stack.fromEscape)
+                                    return;
+
+                                editor.withSavedValues(["inEditMap"], function () {
+                                    this.inEditMap = true;
+
+                                    let range = RangeFind.union(start, editor.selectedRange);
+                                    editor.selectedRange = select ? range : start;
+                                    doTxn(range, editor);
+                                });
+
+                                editor.currentRegister = null;
+                                modes.delay(function () {
+                                    if (mode)
+                                        modes.push(mode);
+                                });
+                            }
+                            finally {
+                                if (!stack.push)
+                                    mappings.popCommand();
+                            }
+                        }
+                    });
+                },
+                { count: true, type: "motion" });
+
+            mappings.add([modes.VISUAL], key,
                 desc,
                 function ({ count,  motion }) {
-                    editor.selectMotion(key, motion, Math.max(count, 1));
-                    if (callable(cmd))
-                        cmd.call(events, Editor.getEditor(null));
-                    else {
-                        editor.executeCommand(cmd, 1);
-                        modes.pop(modes.TEXT_EDIT);
-                    }
-                    if (mode)
-                        modes.push(mode);
+                    dactyl.assert(caretOk || editor.isTextEdit);
+                    if (editor.isTextEdit)
+                        doTxn(editor.selectedRange, editor);
+                    else
+                        cmd(editor, buffer.selection.getRangeAt(0));
                 },
-                { count: true, motion: true });
+                { count: true, type: "motion" });
         }
 
-        addMotionMap("d", "Delete motion", "cmd_delete");
-        addMotionMap("c", "Change motion", "cmd_delete", modes.INSERT);
-        addMotionMap("y", "Yank motion",   "cmd_copy");
-
-        mappings.add([modes.INPUT],
-            ["<C-w>"], "Delete previous word",
-            function () { editor.executeCommand("cmd_deleteWordBackward", 1); });
+        addMotionMap(["d", "x"], "Delete text", true,  function (editor) { editor.cut(); });
+        addMotionMap(["c"],      "Change text", true,  function (editor) { editor.cut(); }, modes.INSERT);
+        addMotionMap(["y"],      "Yank text",   false, function (editor, range) { editor.copy(range); }, null, true);
 
-        mappings.add([modes.INPUT],
-            ["<C-u>"], "Delete until beginning of current line",
-            function () {
-                // Deletes the whole line. What the hell.
-                // editor.executeCommand("cmd_deleteToBeginningOfLine", 1);
+        addMotionMap(["gu"], "Lowercase text", false,
+             function (editor, range) {
+                 editor.mungeRange(range, String.toLocaleLowerCase);
+             });
 
-                editor.executeCommand("cmd_selectBeginLine", 1);
-                if (Editor.getController().isCommandEnabled("cmd_delete"))
-                    editor.executeCommand("cmd_delete", 1);
+        addMotionMap(["gU"], "Uppercase text", false,
+            function (editor, range) {
+                editor.mungeRange(range, String.toLocaleUpperCase);
             });
 
-        mappings.add([modes.INPUT],
-            ["<C-k>"], "Delete until end of current line",
-            function () { editor.executeCommand("cmd_deleteToEndOfLine", 1); });
+        mappings.add([modes.OPERATOR],
+            ["c", "d", "y"], "Select the entire line",
+            function ({ command, count }) {
+                dactyl.assert(command == modes.getStack(0).params.forCommand);
 
-        mappings.add([modes.INPUT],
-            ["<C-a>"], "Move cursor to beginning of current line",
-            function () { editor.executeCommand("cmd_beginLine", 1); });
+                let sel = editor.selection;
+                sel.modify("move", "backward", "lineboundary");
+                sel.modify("extend", "forward", "lineboundary");
 
-        mappings.add([modes.INPUT],
-            ["<C-e>"], "Move cursor to end of current line",
-            function () { editor.executeCommand("cmd_endLine", 1); });
-
-        mappings.add([modes.INPUT],
-            ["<C-h>"], "Delete character to the left",
-            function () { events.feedkeys("<BS>", true); });
-
-        mappings.add([modes.INPUT],
-            ["<C-d>"], "Delete character to the right",
-            function () { editor.executeCommand("cmd_deleteCharForward", 1); });
-
-        mappings.add([modes.INPUT],
-            ["<S-Insert>"], "Insert clipboard/selection",
-            function () { editor.pasteClipboard(); });
-
-        mappings.add([modes.INPUT, modes.TEXT_EDIT],
-            ["<C-i>"], "Edit text field with an external editor",
-            function () { editor.editFieldExternally(); });
-
-        mappings.add([modes.INPUT],
-            ["<C-t>"], "Edit text field in Vi mode",
-            function () {
-                dactyl.assert(dactyl.focusedElement);
-                dactyl.assert(!editor.isTextEdit);
-                modes.push(modes.TEXT_EDIT);
-            });
+                if (command != "c")
+                    sel.modify("extend", "forward", "character");
+            },
+            { count: true, type: "operator" });
+
+        let bind = function bind(names, description, action, params)
+            mappings.add([modes.INPUT], names, description,
+                         action, update({ type: "editor" }, params));
+
+        bind(["<C-w>"], "Delete previous word",
+             function () {
+                 if (editor.editor)
+                     clear(false, /\w/)(editor.editor);
+                 else
+                     editor.executeCommand("cmd_deleteWordBackward", 1);
+             });
+
+        bind(["<C-u>"], "Delete until beginning of current line",
+             function () {
+                 // Deletes the whole line. What the hell.
+                 // editor.executeCommand("cmd_deleteToBeginningOfLine", 1);
+
+                 editor.executeCommand("cmd_selectBeginLine", 1);
+                 if (editor.selection && editor.selection.isCollapsed) {
+                     editor.executeCommand("cmd_deleteCharBackward", 1);
+                     editor.executeCommand("cmd_selectBeginLine", 1);
+                 }
+
+                 if (editor.getController("cmd_delete").isCommandEnabled("cmd_delete"))
+                     editor.executeCommand("cmd_delete", 1);
+             });
+
+        bind(["<C-k>"], "Delete until end of current line",
+             function () { editor.executeCommand("cmd_deleteToEndOfLine", 1); });
+
+        bind(["<C-a>"], "Move cursor to beginning of current line",
+             function () { editor.executeCommand("cmd_beginLine", 1); });
+
+        bind(["<C-e>"], "Move cursor to end of current line",
+             function () { editor.executeCommand("cmd_endLine", 1); });
+
+        bind(["<C-h>"], "Delete character to the left",
+             function () { events.feedkeys("<BS>", true); });
+
+        bind(["<C-d>"], "Delete character to the right",
+             function () { editor.executeCommand("cmd_deleteCharForward", 1); });
+
+        bind(["<S-Insert>"], "Insert clipboard/selection",
+             function () { editor.paste(); });
+
+        bind(["<C-i>"], "Edit text field with an external editor",
+             function () { editor.editFieldExternally(); });
+
+        bind(["<C-t>"], "Edit text field in Text Edit mode",
+             function () {
+                 dactyl.assert(!editor.isTextEdit && editor.editor);
+                 dactyl.assert(dactyl.focusedElement ||
+                               // Sites like Google like to use a
+                               // hidden, editable window for keyboard
+                               // focus and use their own WYSIWYG editor
+                               // implementations for the visible area,
+                               // which we can't handle.
+                               let (f = document.commandDispatcher.focusedWindow.frameElement)
+                                    f && Hints.isVisible(f, true));
+
+                 modes.push(modes.TEXT_EDIT);
+             });
 
         // Ugh.
         mappings.add([modes.INPUT, modes.CARET],
@@ -673,52 +1142,58 @@ var Editor = Module("editor", {
             ["<C-]>", "<C-5>"], "Expand Insert mode abbreviation",
             function () { editor.expandAbbreviation(modes.INSERT); });
 
-        // text edit mode
-        mappings.add([modes.TEXT_EDIT],
-            ["u"], "Undo changes",
-            function (args) {
-                editor.executeCommand("cmd_undo", Math.max(args.count, 1));
-                editor.unselectText();
-            },
-            { count: true });
-
-        mappings.add([modes.TEXT_EDIT],
-            ["<C-r>"], "Redo undone changes",
-            function (args) {
-                editor.executeCommand("cmd_redo", Math.max(args.count, 1));
-                editor.unselectText();
-            },
-            { count: true });
+        let bind = function bind(names, description, action, params)
+            mappings.add([modes.TEXT_EDIT], names, description,
+                         action, update({ type: "editor" }, params));
 
-        mappings.add([modes.TEXT_EDIT],
-            ["D"], "Delete the characters under the cursor until the end of the line",
-            function () { editor.executeCommand("cmd_deleteToEndOfLine"); });
 
-        mappings.add([modes.TEXT_EDIT],
-            ["o"], "Open line below current",
-            function () {
-                editor.executeCommand("cmd_endLine", 1);
-                modes.push(modes.INSERT);
-                events.feedkeys("<Return>");
-            });
+        bind(["<C-a>"], "Increment the next number",
+             function ({ count }) { editor.modifyNumber(count || 1) },
+             { count: true });
 
-        mappings.add([modes.TEXT_EDIT],
-            ["O"], "Open line above current",
-            function () {
-                editor.executeCommand("cmd_beginLine", 1);
-                modes.push(modes.INSERT);
-                events.feedkeys("<Return>");
-                editor.executeCommand("cmd_linePrevious", 1);
-            });
+        bind(["<C-x>"], "Decrement the next number",
+             function ({ count }) { editor.modifyNumber(-(count || 1)) },
+             { count: true });
 
-        mappings.add([modes.TEXT_EDIT],
-            ["X"], "Delete character to the left",
-            function (args) { editor.executeCommand("cmd_deleteCharBackward", Math.max(args.count, 1)); },
+        // text edit mode
+        bind(["u"], "Undo changes",
+             function (args) {
+                 editor.executeCommand("cmd_undo", Math.max(args.count, 1));
+                 editor.deselect();
+             },
+             { count: true });
+
+        bind(["<C-r>"], "Redo undone changes",
+             function (args) {
+                 editor.executeCommand("cmd_redo", Math.max(args.count, 1));
+                 editor.deselect();
+             },
+             { count: true });
+
+        bind(["D"], "Delete characters from the cursor to the end of the line",
+             function () { editor.executeCommand("cmd_deleteToEndOfLine"); });
+
+        bind(["o"], "Open line below current",
+             function () {
+                 editor.executeCommand("cmd_endLine", 1);
+                 modes.push(modes.INSERT);
+                 events.feedkeys("<Return>");
+             });
+
+        bind(["O"], "Open line above current",
+             function () {
+                 editor.executeCommand("cmd_beginLine", 1);
+                 modes.push(modes.INSERT);
+                 events.feedkeys("<Return>");
+                 editor.executeCommand("cmd_linePrevious", 1);
+             });
+
+        bind(["X"], "Delete character to the left",
+             function (args) { editor.executeCommand("cmd_deleteCharBackward", Math.max(args.count, 1)); },
             { count: true });
 
-        mappings.add([modes.TEXT_EDIT],
-            ["x"], "Delete character to the right",
-            function (args) { editor.executeCommand("cmd_deleteCharForward", Math.max(args.count, 1)); },
+        bind(["x"], "Delete character to the right",
+             function (args) { editor.executeCommand("cmd_deleteCharForward", Math.max(args.count, 1)); },
             { count: true });
 
         // visual mode
@@ -730,16 +1205,15 @@ var Editor = Module("editor", {
             ["v", "V"], "End Visual mode",
             function () { modes.pop(); });
 
-        mappings.add([modes.TEXT_EDIT],
-            ["V"], "Start Visual Line mode",
-            function () {
-                modes.push(modes.VISUAL, modes.LINE);
-                editor.executeCommand("cmd_beginLine", 1);
-                editor.executeCommand("cmd_selectLineNext", 1);
-            });
+        bind(["V"], "Start Visual Line mode",
+             function () {
+                 modes.push(modes.VISUAL, modes.LINE);
+                 editor.executeCommand("cmd_beginLine", 1);
+                 editor.executeCommand("cmd_selectLineNext", 1);
+             });
 
         mappings.add([modes.VISUAL],
-            ["c", "s"], "Change selected text",
+            ["s"], "Change selected text",
             function () {
                 dactyl.assert(editor.isTextEdit);
                 editor.executeCommand("cmd_cut");
@@ -747,95 +1221,110 @@ var Editor = Module("editor", {
             });
 
         mappings.add([modes.VISUAL],
-            ["d", "x"], "Delete selected text",
+            ["o"], "Move cursor to the other end of the selection",
             function () {
-                dactyl.assert(editor.isTextEdit);
-                editor.executeCommand("cmd_cut");
-            });
-
-        mappings.add([modes.VISUAL],
-            ["y"], "Yank selected text",
-            function () {
-                if (editor.isTextEdit) {
-                    editor.executeCommand("cmd_copy");
-                    modes.pop();
-                }
+                if (editor.isTextEdit)
+                    var selection = editor.selection;
                 else
-                    dactyl.clipboardWrite(buffer.currentWord, true);
+                    selection = buffer.focusedFrame.getSelection();
+
+                util.assert(selection.focusNode);
+                let { focusOffset, anchorOffset, focusNode, anchorNode } = selection;
+                selection.collapse(focusNode, focusOffset);
+                selection.extend(anchorNode, anchorOffset);
             });
 
-        mappings.add([modes.VISUAL, modes.TEXT_EDIT],
-            ["p"], "Paste clipboard contents",
-            function ({ count }) {
+        bind(["p"], "Paste clipboard contents",
+             function ({ count }) {
                 dactyl.assert(!editor.isCaret);
-                editor.executeCommand("cmd_paste", count || 1);
-                modes.pop(modes.TEXT_EDIT);
+                editor.executeCommand(modules.bind("paste", editor, null),
+                                      count || 1);
             },
             { count: true });
 
-        // finding characters
-        mappings.add([modes.TEXT_EDIT, modes.VISUAL],
-            ["f"], "Move to a character on the current line after the cursor",
-            function ({ arg, count }) {
-                let pos = editor.findChar(arg, Math.max(count, 1));
-                if (pos >= 0)
-                    editor.moveToPosition(pos, true, modes.main == modes.VISUAL);
+        mappings.add([modes.COMMAND],
+            ['"'], "Bind a register to the next command",
+            function ({ arg }) {
+                editor.pushRegister(arg);
             },
-            { arg: true, count: true });
+            { arg: true });
 
-        mappings.add([modes.TEXT_EDIT, modes.VISUAL],
-            ["F"], "Move to a character on the current line before the cursor",
-            function ({ arg, count }) {
-                let pos = editor.findChar(arg, Math.max(count, 1), true);
-                if (pos >= 0)
-                    editor.moveToPosition(pos, false, modes.main == modes.VISUAL);
+        mappings.add([modes.INPUT],
+            ["<C-'>", '<C-">'], "Bind a register to the next command",
+            function ({ arg }) {
+                editor.pushRegister(arg);
             },
-            { arg: true, count: true });
+            { arg: true });
 
-        mappings.add([modes.TEXT_EDIT, modes.VISUAL],
-            ["t"], "Move before a character on the current line",
-            function ({ arg, count }) {
-                let pos = editor.findChar(arg, Math.max(count, 1));
-                if (pos >= 0)
-                    editor.moveToPosition(pos - 1, true, modes.main == modes.VISUAL);
-            },
-            { arg: true, count: true });
+        let bind = function bind(names, description, action, params)
+            mappings.add([modes.TEXT_EDIT, modes.OPERATOR, modes.VISUAL],
+                         names, description,
+                         action, update({ type: "editor" }, params));
 
-        mappings.add([modes.TEXT_EDIT, modes.VISUAL],
-            ["T"], "Move before a character on the current line, backwards",
-            function ({ arg, count }) {
-                let pos = editor.findChar(arg, Math.max(count, 1), true);
-                if (pos >= 0)
-                    editor.moveToPosition(pos + 1, false, modes.main == modes.VISUAL);
-            },
-            { arg: true, count: true });
+        // finding characters
+        function offset(backward, before, pos) {
+            if (!backward && modes.main != modes.TEXT_EDIT)
+                return before ? 0 : 1;
+            if (before)
+                return backward ? +1 : -1;
+            return 0;
+        }
+
+        bind(["f"], "Find a character on the current line, forwards",
+             function ({ arg, count }) {
+                 editor.moveToPosition(editor.findChar(arg, Math.max(count, 1), false,
+                                                       offset(false, false)),
+                                       modes.main == modes.VISUAL);
+             },
+             { arg: true, count: true, type: "operator" });
+
+        bind(["F"], "Find a character on the current line, backwards",
+             function ({ arg, count }) {
+                 editor.moveToPosition(editor.findChar(arg, Math.max(count, 1), true,
+                                                       offset(true, false)),
+                                       modes.main == modes.VISUAL);
+             },
+             { arg: true, count: true, type: "operator" });
+
+        bind(["t"], "Find a character on the current line, forwards, and move to the character before it",
+             function ({ arg, count }) {
+                 editor.moveToPosition(editor.findChar(arg, Math.max(count, 1), false,
+                                                       offset(false, true)),
+                                       modes.main == modes.VISUAL);
+             },
+             { arg: true, count: true, type: "operator" });
+
+        bind(["T"], "Find a character on the current line, backwards, and move to the character after it",
+             function ({ arg, count }) {
+                 editor.moveToPosition(editor.findChar(arg, Math.max(count, 1), true,
+                                                       offset(true, true)),
+                                       modes.main == modes.VISUAL);
+             },
+             { arg: true, count: true, type: "operator" });
 
         // text edit and visual mode
         mappings.add([modes.TEXT_EDIT, modes.VISUAL],
             ["~"], "Switch case of the character under the cursor and move the cursor to the right",
             function ({ count }) {
-                if (modes.main == modes.VISUAL)
-                    count = Editor.getEditor().selectionEnd - Editor.getEditor().selectionStart;
-                count = Math.max(count, 1);
-
-                // FIXME: do this in one pass?
-                while (count-- > 0) {
-                    let text = Editor.getEditor().value;
-                    let pos = Editor.getEditor().selectionStart;
-                    dactyl.assert(pos < text.length);
-
-                    let chr = text[pos];
-                    Editor.getEditor().value = text.substring(0, pos) +
-                        (chr == chr.toLocaleLowerCase() ? chr.toLocaleUpperCase() : chr.toLocaleLowerCase()) +
-                        text.substring(pos + 1);
-                    editor.moveToPosition(pos + 1, true, false);
+                function munger(range)
+                    String(range).replace(/./g, function (c) {
+                        let lc = c.toLocaleLowerCase();
+                        return c == lc ? c.toLocaleUpperCase() : lc;
+                    });
+
+                var range = editor.selectedRange;
+                if (range.collapsed) {
+                    count = count || 1;
+                    Editor.extendRange(range, true, { test: function (c) !!count-- }, true);
                 }
+                editor.mungeRange(range, munger, count != null);
+
                 modes.pop(modes.TEXT_EDIT);
             },
             { count: true });
 
-        function bind() mappings.add.apply(mappings,
-                                           [[modes.AUTOCOMPLETE]].concat(Array.slice(arguments)))
+        let bind = function bind() mappings.add.apply(mappings,
+                                                      [[modes.AUTOCOMPLETE]].concat(Array.slice(arguments)))
 
         bind(["<Esc>"], "Return to Insert mode",
              function () Events.PASS_THROUGH);
@@ -855,8 +1344,7 @@ var Editor = Module("editor", {
         bind(["<C-n>"], "Select the next autocomplete result",
              function () { events.feedkeys("<Down>", { skipmap: true }); });
     },
-
-    options: function () {
+    options: function init_options() {
         options.add(["editor"],
             "The external text editor",
             "string", 'gvim -f +<line> +"sil! call cursor(0, <column>)" <file>', {
@@ -878,6 +1366,42 @@ var Editor = Module("editor", {
         options.add(["insertmode", "im"],
             "Enter Insert mode rather than Text Edit mode when focusing text areas",
             "boolean", true);
+
+        options.add(["spelllang", "spl"],
+            "The language used by the spell checker",
+            "string", config.locale,
+            {
+                initValue: function () {},
+                getter: function getter() {
+                    try {
+                        return services.spell.dictionary || "";
+                    }
+                    catch (e) {
+                        return "";
+                    }
+                },
+                setter: function setter(val) { services.spell.dictionary = val; },
+                completer: function completer(context) {
+                    let res = {};
+                    services.spell.getDictionaryList(res, {});
+                    context.completions = res.value;
+                    context.keys = { text: util.identity, description: util.identity };
+                }
+            });
+    },
+    sanitizer: function () {
+        sanitizer.addItem("registers", {
+            description: "Register values",
+            persistent: true,
+            action: function (timespan, host) {
+                if (!host) {
+                    for (let [k, v] in editor.registers)
+                        if (timespan.contains(v.timestamp))
+                            editor.registers.remove(k);
+                    editor.registerRing.truncate(0);
+                }
+            }
+        });
     }
 });