UNPKG

soma.js

Version:

soma.js is a javascript framework created to build scalable and maintainable applications.

1,263 lines (1,246 loc) 83.6 kB
/** * Supported keybindings: * * Motion: * h, j, k, l * e, E, w, W, b, B, ge, gE * f<character>, F<character>, t<character>, T<character> * $, ^, 0 * gg, G * % * '<character>, `<character> * * Operator: * d, y, c * dd, yy, cc * g~, g~g~ * >, <, >>, << * * Operator-Motion: * x, X, D, Y, C, ~ * * Action: * a, i, s, A, I, S, o, O * J * u, Ctrl-r * m<character> * r<character> * * Modes: * ESC - leave insert mode, visual mode, and clear input state. * Ctrl-[, Ctrl-c - same as ESC. * * Registers: unamed, -, a-z, A-Z, 0-9 * (Does not respect the special case for number registers when delete * operator is made with these commands: %, (, ), , /, ?, n, N, {, } ) * TODO: Implement the remaining registers. * Marks: a-z, A-Z, and 0-9 * TODO: Implement the remaining special marks. They have more complex * behavior. * * Code structure: * 1. Default keymap * 2. Variable declarations and short basic helpers * 3. Instance (External API) implementation * 4. Internal state tracking objects (input state, counter) implementation * and instanstiation * 5. Key handler (the main command dispatcher) implementation * 6. Motion, operator, and action implementations * 7. Helper functions for the key handler, motions, operators, and actions * 8. Set up Vim to work as a keymap for CodeMirror. */ (function() { 'use strict'; var defaultKeymap = [ // Key to key mapping. This goes first to make it possible to override // existing mappings. { keys: ['Left'], type: 'keyToKey', toKeys: ['h'] }, { keys: ['Right'], type: 'keyToKey', toKeys: ['l'] }, { keys: ['Up'], type: 'keyToKey', toKeys: ['k'] }, { keys: ['Down'], type: 'keyToKey', toKeys: ['j'] }, { keys: ['Space'], type: 'keyToKey', toKeys: ['l'] }, { keys: ['Backspace'], type: 'keyToKey', toKeys: ['h'] }, { keys: ['Ctrl-Space'], type: 'keyToKey', toKeys: ['W'] }, { keys: ['Ctrl-Backspace'], type: 'keyToKey', toKeys: ['B'] }, { keys: ['Shift-Space'], type: 'keyToKey', toKeys: ['w'] }, { keys: ['Shift-Backspace'], type: 'keyToKey', toKeys: ['b'] }, { keys: ['Ctrl-n'], type: 'keyToKey', toKeys: ['j'] }, { keys: ['Ctrl-p'], type: 'keyToKey', toKeys: ['k'] }, { keys: ['Ctrl-['], type: 'keyToKey', toKeys: ['Esc'] }, { keys: ['Ctrl-c'], type: 'keyToKey', toKeys: ['Esc'] }, { keys: ['s'], type: 'keyToKey', toKeys: ['c', 'l'] }, { keys: ['S'], type: 'keyToKey', toKeys: ['c', 'c'] }, { keys: ['Home'], type: 'keyToKey', toKeys: ['0'] }, { keys: ['End'], type: 'keyToKey', toKeys: ['$'] }, { keys: ['PageUp'], type: 'keyToKey', toKeys: ['Ctrl-b'] }, { keys: ['PageDown'], type: 'keyToKey', toKeys: ['Ctrl-f'] }, // Motions { keys: ['h'], type: 'motion', motion: 'moveByCharacters', motionArgs: { forward: false }}, { keys: ['l'], type: 'motion', motion: 'moveByCharacters', motionArgs: { forward: true }}, { keys: ['j'], type: 'motion', motion: 'moveByLines', motionArgs: { forward: true, linewise: true }}, { keys: ['k'], type: 'motion', motion: 'moveByLines', motionArgs: { forward: false, linewise: true }}, { keys: ['w'], type: 'motion', motion: 'moveByWords', motionArgs: { forward: true, wordEnd: false }}, { keys: ['W'], type: 'motion', motion: 'moveByWords', motionArgs: { forward: true, wordEnd: false, bigWord: true }}, { keys: ['e'], type: 'motion', motion: 'moveByWords', motionArgs: { forward: true, wordEnd: true, inclusive: true }}, { keys: ['E'], type: 'motion', motion: 'moveByWords', motionArgs: { forward: true, wordEnd: true, bigWord: true, inclusive: true }}, { keys: ['b'], type: 'motion', motion: 'moveByWords', motionArgs: { forward: false, wordEnd: false }}, { keys: ['B'], type: 'motion', motion: 'moveByWords', motionArgs: { forward: false, wordEnd: false, bigWord: true }}, { keys: ['g', 'e'], type: 'motion', motion: 'moveByWords', motionArgs: { forward: false, wordEnd: true, inclusive: true }}, { keys: ['g', 'E'], type: 'motion', motion: 'moveByWords', motionArgs: { forward: false, wordEnd: true, bigWord: true, inclusive: true }}, { keys: ['Ctrl-f'], type: 'motion', motion: 'moveByPage', motionArgs: { forward: true }}, { keys: ['Ctrl-b'], type: 'motion', motion: 'moveByPage', motionArgs: { forward: false }}, { keys: ['g', 'g'], type: 'motion', motion: 'moveToLineOrEdgeOfDocument', motionArgs: { forward: false, explicitRepeat: true, linewise: true }}, { keys: ['G'], type: 'motion', motion: 'moveToLineOrEdgeOfDocument', motionArgs: { forward: true, explicitRepeat: true, linewise: true }}, { keys: ['0'], type: 'motion', motion: 'moveToStartOfLine' }, { keys: ['^'], type: 'motion', motion: 'moveToFirstNonWhiteSpaceCharacter' }, { keys: ['$'], type: 'motion', motion: 'moveToEol', motionArgs: { inclusive: true }}, { keys: ['%'], type: 'motion', motion: 'moveToMatchedSymbol', motionArgs: { inclusive: true }}, { keys: ['f', 'character'], type: 'motion', motion: 'moveToCharacter', motionArgs: { forward: true , inclusive: true }}, { keys: ['F', 'character'], type: 'motion', motion: 'moveToCharacter', motionArgs: { forward: false }}, { keys: ['t', 'character'], type: 'motion', motion: 'moveTillCharacter', motionArgs: { forward: true, inclusive: true }}, { keys: ['T', 'character'], type: 'motion', motion: 'moveTillCharacter', motionArgs: { forward: false }}, { keys: ['\'', 'character'], type: 'motion', motion: 'goToMark' }, { keys: ['`', 'character'], type: 'motion', motion: 'goToMark' }, { keys: ['|'], type: 'motion', motion: 'moveToColumn', motionArgs: { }}, // Operators { keys: ['d'], type: 'operator', operator: 'delete' }, { keys: ['y'], type: 'operator', operator: 'yank' }, { keys: ['c'], type: 'operator', operator: 'change', operatorArgs: { enterInsertMode: true } }, { keys: ['>'], type: 'operator', operator: 'indent', operatorArgs: { indentRight: true }}, { keys: ['<'], type: 'operator', operator: 'indent', operatorArgs: { indentRight: false }}, { keys: ['g', '~'], type: 'operator', operator: 'swapcase' }, { keys: ['n'], type: 'motion', motion: 'findNext' }, { keys: ['N'], type: 'motion', motion: 'findPrev' }, // Operator-Motion dual commands { keys: ['x'], type: 'operatorMotion', operator: 'delete', motion: 'moveByCharacters', motionArgs: { forward: true }, operatorMotionArgs: { visualLine: false }}, { keys: ['X'], type: 'operatorMotion', operator: 'delete', motion: 'moveByCharacters', motionArgs: { forward: false }, operatorMotionArgs: { visualLine: true }}, { keys: ['D'], type: 'operatorMotion', operator: 'delete', motion: 'moveToEol', motionArgs: { inclusive: true }, operatorMotionArgs: { visualLine: true }}, { keys: ['Y'], type: 'operatorMotion', operator: 'yank', motion: 'moveToEol', motionArgs: { inclusive: true }, operatorMotionArgs: { visualLine: true }}, { keys: ['C'], type: 'operatorMotion', operator: 'change', operatorArgs: { enterInsertMode: true }, motion: 'moveToEol', motionArgs: { inclusive: true }, operatorMotionArgs: { visualLine: true }}, { keys: ['~'], type: 'operatorMotion', operator: 'swapcase', motion: 'moveByCharacters', motionArgs: { forward: true }}, // Actions { keys: ['a'], type: 'action', action: 'enterInsertMode', actionArgs: { insertAt: 'charAfter' }}, { keys: ['A'], type: 'action', action: 'enterInsertMode', actionArgs: { insertAt: 'eol' }}, { keys: ['i'], type: 'action', action: 'enterInsertMode' }, { keys: ['I'], type: 'action', action: 'enterInsertMode', motion: 'moveToFirstNonWhiteSpaceCharacter' }, { keys: ['o'], type: 'action', action: 'newLineAndEnterInsertMode', actionArgs: { after: true }}, { keys: ['O'], type: 'action', action: 'newLineAndEnterInsertMode', actionArgs: { after: false }}, { keys: ['v'], type: 'action', action: 'toggleVisualMode' }, { keys: ['V'], type: 'action', action: 'toggleVisualMode', actionArgs: { linewise: true }}, { keys: ['J'], type: 'action', action: 'joinLines' }, { keys: ['p'], type: 'action', action: 'paste', actionArgs: { after: true }}, { keys: ['P'], type: 'action', action: 'paste', actionArgs: { after: false }}, { keys: ['r', 'character'], type: 'action', action: 'replace' }, { keys: ['u'], type: 'action', action: 'undo' }, { keys: ['Ctrl-r'], type: 'action', action: 'redo' }, { keys: ['m', 'character'], type: 'action', action: 'setMark' }, { keys: ['\"', 'character'], type: 'action', action: 'setRegister' }, { keys: [',', '/'], type: 'action', action: 'clearSearchHighlight' }, // Text object motions { keys: ['a', 'character'], type: 'motion', motion: 'textObjectManipulation' }, { keys: ['i', 'character'], type: 'motion', motion: 'textObjectManipulation', motionArgs: { textObjectInner: true }}, // Search { keys: ['/'], type: 'search', searchArgs: { forward: true, querySrc: 'prompt' }}, { keys: ['?'], type: 'search', searchArgs: { forward: false, querySrc: 'prompt' }}, { keys: ['*'], type: 'search', searchArgs: { forward: true, querySrc: 'wordUnderCursor' }}, { keys: ['#'], type: 'search', searchArgs: { forward: false, querySrc: 'wordUnderCursor' }}, // Ex command { keys: [':'], type: 'ex' } ]; var Vim = function() { var alphabetRegex = /[A-Za-z]/; var numberRegex = /[\d]/; var whiteSpaceRegex = /\s/; var wordRegexp = [(/\w/), (/[^\w\s]/)], bigWordRegexp = [(/\S/)]; function makeKeyRange(start, size) { var keys = []; for (var i = start; i < start + size; i++) { keys.push(String.fromCharCode(i)); } return keys; } var upperCaseAlphabet = makeKeyRange(65, 26); var lowerCaseAlphabet = makeKeyRange(97, 26); var numbers = makeKeyRange(48, 10); var SPECIAL_SYMBOLS = '~`!@#$%^&*()_-+=[{}]\\|/?.,<>:;\"\''; var specialSymbols = SPECIAL_SYMBOLS.split(''); var specialKeys = ['Left', 'Right', 'Up', 'Down', 'Space', 'Backspace', 'Esc', 'Home', 'End', 'PageUp', 'PageDown']; var validMarks = upperCaseAlphabet.concat(lowerCaseAlphabet).concat( numbers); var validRegisters = upperCaseAlphabet.concat(lowerCaseAlphabet).concat( numbers).concat('-\"'.split('')); function isAlphabet(k) { return alphabetRegex.test(k); } function isLine(cm, line) { return line >= 0 && line < cm.lineCount(); } function isLowerCase(k) { return (/^[a-z]$/).test(k); } function isMatchableSymbol(k) { return '()[]{}'.indexOf(k) != -1; } function isNumber(k) { return numberRegex.test(k); } function isUpperCase(k) { return (/^[A-Z]$/).test(k); } function isAlphanumeric(k) { return (/^[\w]$/).test(k); } function isWhiteSpace(k) { return whiteSpaceRegex.test(k); } function isWhiteSpaceString(k) { return (/^\s*$/).test(k); } function inRangeInclusive(x, start, end) { return x >= start && x <= end; } function inArray(val, arr) { for (var i = 0; i < arr.length; i++) { if (arr[i] == val) { return true; } } return false; } // Global Vim state. Call getVimGlobalState to get and initialize. var vimGlobalState; function getVimGlobalState() { if (!vimGlobalState) { vimGlobalState = { // The current search query. searchQuery: null, // Whether we are searching backwards. searchIsReversed: false, registerController: new RegisterController({}) }; } return vimGlobalState; } function getVimState(cm) { if (!cm.vimState) { // Store instance state in the CodeMirror object. cm.vimState = { inputState: new InputState(), // When using jk for navigation, if you move from a longer line to a // shorter line, the cursor may clip to the end of the shorter line. // If j is pressed again and cursor goes to the next line, the // cursor should go back to its horizontal position on the longer // line if it can. This is to keep track of the horizontal position. lastHPos: -1, // The last motion command run. Cleared if a non-motion command gets // executed in between. lastMotion: null, marks: {}, visualMode: false, // If we are in visual line mode. No effect if visualMode is false. visualLine: false }; } return cm.vimState; } var vimApi= { buildKeyMap: function() { // TODO: Convert keymap into dictionary format for fast lookup. }, // Testing hook, though it might be useful to expose the register // controller anyways. getRegisterController: function() { return getVimGlobalState().registerController; }, // Testing hook. clearVimGlobalState_: function() { vimGlobalState = null; }, map: function(lhs, rhs) { // Add user defined key bindings. exCommandDispatcher.map(lhs, rhs); }, // Initializes vim state variable on the CodeMirror object. Should only be // called lazily by handleKey or for testing. maybeInitState: function(cm) { getVimState(cm); }, // This is the outermost function called by CodeMirror, after keys have // been mapped to their Vim equivalents. handleKey: function(cm, key) { var command; var vim = getVimState(cm); if (key == 'Esc') { // Clear input state and get back to normal mode. vim.inputState.reset(); if (vim.visualMode) { exitVisualMode(cm, vim); } return; } if (vim.visualMode && cursorEqual(cm.getCursor('head'), cm.getCursor('anchor'))) { // The selection was cleared. Exit visual mode. exitVisualMode(cm, vim); } if (!vim.visualMode && !cursorEqual(cm.getCursor('head'), cm.getCursor('anchor'))) { vim.visualMode = true; vim.visualLine = false; } if (key != '0' || (key == '0' && vim.inputState.getRepeat() === 0)) { // Have to special case 0 since it's both a motion and a number. command = commandDispatcher.matchCommand(key, defaultKeymap, vim); } if (!command) { if (isNumber(key)) { // Increment count unless count is 0 and key is 0. vim.inputState.pushRepeatDigit(key); } return; } if (command.type == 'keyToKey') { // TODO: prevent infinite recursion. for (var i = 0; i < command.toKeys.length; i++) { this.handleKey(cm, command.toKeys[i]); } } else { commandDispatcher.processCommand(cm, vim, command); } } }; // Represents the current input state. function InputState() { this.reset(); } InputState.prototype.reset = function() { this.prefixRepeat = []; this.motionRepeat = []; this.operator = null; this.operatorArgs = null; this.motion = null; this.motionArgs = null; this.keyBuffer = []; // For matching multi-key commands. this.registerName = null; // Defaults to the unamed register. }; InputState.prototype.pushRepeatDigit = function(n) { if (!this.operator) { this.prefixRepeat = this.prefixRepeat.concat(n); } else { this.motionRepeat = this.motionRepeat.concat(n); } }; InputState.prototype.getRepeat = function() { var repeat = 0; if (this.prefixRepeat.length > 0 || this.motionRepeat.length > 0) { repeat = 1; if (this.prefixRepeat.length > 0) { repeat *= parseInt(this.prefixRepeat.join(''), 10); } if (this.motionRepeat.length > 0) { repeat *= parseInt(this.motionRepeat.join(''), 10); } } return repeat; }; /* * Register stores information about copy and paste registers. Besides * text, a register must store whether it is linewise (i.e., when it is * pasted, should it insert itself into a new line, or should the text be * inserted at the cursor position.) */ function Register(text, linewise) { this.clear(); if (text) { this.set(text, linewise); } } Register.prototype = { set: function(text, linewise) { this.text = text; this.linewise = !!linewise; }, append: function(text, linewise) { // if this register has ever been set to linewise, use linewise. if (linewise || this.linewise) { this.text += '\n' + text; this.linewise = true; } else { this.text += text; } }, clear: function() { this.text = ''; this.linewise = false; }, toString: function() { return this.text; } }; /* * vim registers allow you to keep many independent copy and paste buffers. * See http://usevim.com/2012/04/13/registers/ for an introduction. * * RegisterController keeps the state of all the registers. An initial * state may be passed in. The unnamed register '"' will always be * overridden. */ function RegisterController(registers) { this.registers = registers; this.unamedRegister = registers['\"'] = new Register(); } RegisterController.prototype = { pushText: function(registerName, operator, text, linewise) { // Lowercase and uppercase registers refer to the same register. // Uppercase just means append. var register = this.isValidRegister(registerName) ? this.getRegister(registerName) : null; // if no register/an invalid register was specified, things go to the // default registers if (!register) { switch (operator) { case 'yank': // The 0 register contains the text from the most recent yank. this.registers['0'] = new Register(text, linewise); break; case 'delete': case 'change': if (text.indexOf('\n') == -1) { // Delete less than 1 line. Update the small delete register. this.registers['-'] = new Register(text, linewise); } else { // Shift down the contents of the numbered registers and put the // deleted text into register 1. this.shiftNumericRegisters_(); this.registers['1'] = new Register(text, linewise); } break; } // Make sure the unnamed register is set to what just happened this.unamedRegister.set(text, linewise); return; } // If we've gotten to this point, we've actually specified a register var append = isUpperCase(registerName); if (append) { register.append(text, linewise); // The unamed register always has the same value as the last used // register. this.unamedRegister.append(text, linewise); } else { register.set(text, linewise); this.unamedRegister.set(text, linewise); } }, // Gets the register named @name. If one of @name doesn't already exist, // create it. If @name is invalid, return the unamedRegister. getRegister: function(name) { if (!this.isValidRegister(name)) { return this.unamedRegister; } name = name.toLowerCase(); if (!this.registers[name]) { this.registers[name] = new Register(); } return this.registers[name]; }, isValidRegister: function(name) { return name && inArray(name, validRegisters); }, shiftNumericRegisters_: function() { for (var i = 9; i >= 2; i--) { this.registers[i] = this.getRegister('' + (i - 1)); } } }; var commandDispatcher = { matchCommand: function(key, keyMap, vim) { var inputState = vim.inputState; var keys = inputState.keyBuffer.concat(key); for (var i = 0; i < keyMap.length; i++) { var command = keyMap[i]; if (matchKeysPartial(keys, command.keys)) { if (keys.length < command.keys.length) { // Matches part of a multi-key command. Buffer and wait for next // stroke. inputState.keyBuffer.push(key); return null; } else { if (inputState.operator && command.type == 'action') { // Ignore matched action commands after an operator. Operators // only operate on motions. This check is really for text // objects since aW, a[ etcs conflicts with a. continue; } // Matches whole comand. Return the command. if (command.keys[keys.length - 1] == 'character') { inputState.selectedCharacter = keys[keys.length - 1]; } inputState.keyBuffer = []; return command; } } } // Clear the buffer since there are no partial matches. inputState.keyBuffer = []; return null; }, processCommand: function(cm, vim, command) { switch (command.type) { case 'motion': this.processMotion(cm, vim, command); break; case 'operator': this.processOperator(cm, vim, command); break; case 'operatorMotion': this.processOperatorMotion(cm, vim, command); break; case 'action': this.processAction(cm, vim, command); break; case 'search': this.processSearch(cm, vim, command); break; case 'ex': case 'keyToEx': this.processEx(cm, vim, command); break; default: break; } }, processMotion: function(cm, vim, command) { vim.inputState.motion = command.motion; vim.inputState.motionArgs = copyArgs(command.motionArgs); this.evalInput(cm, vim); }, processOperator: function(cm, vim, command) { var inputState = vim.inputState; if (inputState.operator) { if (inputState.operator == command.operator) { // Typing an operator twice like 'dd' makes the operator operate // linewise inputState.motion = 'expandToLine'; inputState.motionArgs = { linewise: true }; this.evalInput(cm, vim); return; } else { // 2 different operators in a row doesn't make sense. inputState.reset(); } } inputState.operator = command.operator; inputState.operatorArgs = copyArgs(command.operatorArgs); if (vim.visualMode) { // Operating on a selection in visual mode. We don't need a motion. this.evalInput(cm, vim); } }, processOperatorMotion: function(cm, vim, command) { var visualMode = vim.visualMode; var operatorMotionArgs = copyArgs(command.operatorMotionArgs); if (operatorMotionArgs) { // Operator motions may have special behavior in visual mode. if (visualMode && operatorMotionArgs.visualLine) { vim.visualLine = true; } } this.processOperator(cm, vim, command); if (!visualMode) { this.processMotion(cm, vim, command); } }, processAction: function(cm, vim, command) { var inputState = vim.inputState; var repeat = inputState.getRepeat(); var repeatIsExplicit = !!repeat; var actionArgs = copyArgs(command.actionArgs) || {}; if (inputState.selectedCharacter) { actionArgs.selectedCharacter = inputState.selectedCharacter; } // Actions may or may not have motions and operators. Do these first. if (command.operator) { this.processOperator(cm, vim, command); } if (command.motion) { this.processMotion(cm, vim, command); } if (command.motion || command.operator) { this.evalInput(cm, vim); } actionArgs.repeat = repeat || 1; actionArgs.repeatIsExplicit = repeatIsExplicit; actionArgs.registerName = inputState.registerName; inputState.reset(); vim.lastMotion = null, actions[command.action](cm, actionArgs, vim); }, processSearch: function(cm, vim, command) { if (!cm.getSearchCursor) { // Search depends on SearchCursor. return; } var forward = command.searchArgs.forward; getSearchState(cm).setReversed(!forward); var promptPrefix = (forward) ? '/' : '?'; function handleQuery(query, ignoreCase, smartCase) { updateSearchQuery(cm, query, ignoreCase, smartCase); commandDispatcher.processMotion(cm, vim, { type: 'motion', motion: 'findNext' }); } function onPromptClose(query) { handleQuery(query, true /** ignoreCase */, true /** smartCase */); } switch (command.searchArgs.querySrc) { case 'prompt': showPrompt(cm, onPromptClose, promptPrefix, searchPromptDesc); break; case 'wordUnderCursor': var word = expandWordUnderCursor(cm, false /** inclusive */, true /** forward */, false /** bigWord */, true /** noSymbol */); var isKeyword = true; if (!word) { word = expandWordUnderCursor(cm, false /** inclusive */, true /** forward */, false /** bigWord */, false /** noSymbol */); isKeyword = false; } if (!word) { return; } var query = cm.getLine(word.start.line).substring(word.start.ch, word.end.ch + 1); if (isKeyword) { query = '\\b' + query + '\\b'; } else { query = escapeRegex(query); } cm.setCursor(word.start); handleQuery(query, true /** ignoreCase */, false /** smartCase */); break; } }, processEx: function(cm, vim, command) { function onPromptClose(input) { exCommandDispatcher.processCommand(cm, input); } if (command.type == 'keyToEx') { // Handle user defined Ex to Ex mappings exCommandDispatcher.processCommand(cm, command.exArgs.input); } else { showPrompt(cm, onPromptClose, ':'); } }, evalInput: function(cm, vim) { // If the motion comand is set, execute both the operator and motion. // Otherwise return. var inputState = vim.inputState; var motion = inputState.motion; var motionArgs = inputState.motionArgs || {}; var operator = inputState.operator; var operatorArgs = inputState.operatorArgs || {}; var registerName = inputState.registerName; var selectionEnd = cm.getCursor('head'); var selectionStart = cm.getCursor('anchor'); // The difference between cur and selection cursors are that cur is // being operated on and ignores that there is a selection. var curStart = copyCursor(selectionEnd); var curOriginal = copyCursor(curStart); var curEnd; var repeat; if (motionArgs.repeat !== undefined) { // If motionArgs specifies a repeat, that takes precedence over the // input state's repeat. Used by Ex mode and can be user defined. repeat = inputState.motionArgs.repeat; } else { repeat = inputState.getRepeat(); } if (repeat > 0 && motionArgs.explicitRepeat) { motionArgs.repeatIsExplicit = true; } else if (motionArgs.noRepeat || (!motionArgs.explicitRepeat && repeat === 0)) { repeat = 1; motionArgs.repeatIsExplicit = false; } if (inputState.selectedCharacter) { // If there is a character input, stick it in all of the arg arrays. motionArgs.selectedCharacter = operatorArgs.selectedCharacter = inputState.selectedCharacter; } motionArgs.repeat = repeat; inputState.reset(); if (motion) { var motionResult = motions[motion](cm, motionArgs, vim); vim.lastMotion = motions[motion]; if (!motionResult) { return; } if (motionResult instanceof Array) { curStart = motionResult[0]; curEnd = motionResult[1]; } else { curEnd = motionResult; } // TODO: Handle null returns from motion commands better. if (!curEnd) { curEnd = { ch: curStart.ch, line: curStart.line }; } if (vim.visualMode) { // Check if the selection crossed over itself. Will need to shift // the start point if that happened. if (cursorIsBefore(selectionStart, selectionEnd) && (cursorEqual(selectionStart, curEnd) || cursorIsBefore(curEnd, selectionStart))) { // The end of the selection has moved from after the start to // before the start. We will shift the start right by 1. selectionStart.ch += 1; } else if (cursorIsBefore(selectionEnd, selectionStart) && (cursorEqual(selectionStart, curEnd) || cursorIsBefore(selectionStart, curEnd))) { // The opposite happened. We will shift the start left by 1. selectionStart.ch -= 1; } selectionEnd = curEnd; if (vim.visualLine) { if (cursorIsBefore(selectionStart, selectionEnd)) { selectionStart.ch = 0; selectionEnd.ch = lineLength(cm, selectionEnd.line); } else { selectionEnd.ch = 0; selectionStart.ch = lineLength(cm, selectionStart.line); } } // Need to set the cursor to clear the selection. Otherwise, // CodeMirror can't figure out that we changed directions... cm.setCursor(selectionStart); cm.setSelection(selectionStart, selectionEnd); } else if (!operator) { curEnd = clipCursorToContent(cm, curEnd); cm.setCursor(curEnd.line, curEnd.ch); } } if (operator) { var inverted = false; vim.lastMotion = null; operatorArgs.repeat = repeat; // Indent in visual mode needs this. if (vim.visualMode) { curStart = selectionStart; curEnd = selectionEnd; motionArgs.inclusive = true; } // Swap start and end if motion was backward. if (cursorIsBefore(curEnd, curStart)) { var tmp = curStart; curStart = curEnd; curEnd = tmp; inverted = true; } if (motionArgs.inclusive && !(vim.visualMode && inverted)) { // Move the selection end one to the right to include the last // character. curEnd.ch++; } var linewise = motionArgs.linewise || (vim.visualMode && vim.visualLine); if (linewise) { // Expand selection to entire line. expandSelectionToLine(cm, curStart, curEnd); } else if (motionArgs.forward) { // Clip to trailing newlines only if we the motion goes forward. clipToLine(cm, curStart, curEnd); } operatorArgs.registerName = registerName; // Keep track of linewise as it affects how paste and change behave. operatorArgs.linewise = linewise; operators[operator](cm, operatorArgs, vim, curStart, curEnd, curOriginal); if (vim.visualMode) { exitVisualMode(cm, vim); } if (operatorArgs.enterInsertMode) { actions.enterInsertMode(cm); } } } }; /** * typedef {Object{line:number,ch:number}} Cursor An object containing the * position of the cursor. */ // All of the functions below return Cursor objects. var motions = { expandToLine: function(cm, motionArgs) { // Expands forward to end of line, and then to next line if repeat is // >1. Does not handle backward motion! var cur = cm.getCursor(); return { line: cur.line + motionArgs.repeat - 1, ch: Infinity }; }, findNext: function(cm, motionArgs, vim) { return findNext(cm, false /** prev */, motionArgs.repeat); }, findPrev: function(cm, motionArgs, vim) { return findNext(cm, true /** prev */, motionArgs.repeat); }, goToMark: function(cm, motionArgs, vim) { var mark = vim.marks[motionArgs.selectedCharacter]; if (mark) { return mark.find(); } return null; }, moveByCharacters: function(cm, motionArgs) { var cur = cm.getCursor(); var repeat = motionArgs.repeat; var ch = motionArgs.forward ? cur.ch + repeat : cur.ch - repeat; return { line: cur.line, ch: ch }; }, moveByLines: function(cm, motionArgs, vim) { var endCh = cm.getCursor().ch; // Depending what our last motion was, we may want to do different // things. If our last motion was moving vertically, we want to // preserve the HPos from our last horizontal move. If our last motion // was going to the end of a line, moving vertically we should go to // the end of the line, etc. switch (vim.lastMotion) { case this.moveByLines: case this.moveToColumn: case this.moveToEol: endCh = vim.lastHPos; break; default: vim.lastHPos = endCh; } var cur = cm.getCursor(); var repeat = motionArgs.repeat; var line = motionArgs.forward ? cur.line + repeat : cur.line - repeat; if (line < 0 || line > cm.lineCount() - 1) { return null; } return { line: line, ch: endCh }; }, moveByPage: function(cm, motionArgs) { // CodeMirror only exposes functions that move the cursor page down, so // doing this bad hack to move the cursor and move it back. evalInput // will move the cursor to where it should be in the end. var curStart = cm.getCursor(); var repeat = motionArgs.repeat; cm.moveV((motionArgs.forward ? repeat : -repeat), 'page'); var curEnd = cm.getCursor(); cm.setCursor(curStart); return curEnd; }, moveByWords: function(cm, motionArgs) { return moveToWord(cm, motionArgs.repeat, !!motionArgs.forward, !!motionArgs.wordEnd, !!motionArgs.bigWord); }, moveTillCharacter: function(cm, motionArgs) { var repeat = motionArgs.repeat; var curEnd = moveToCharacter(cm, repeat, motionArgs.forward, motionArgs.selectedCharacter); var increment = motionArgs.forward ? -1 : 1; curEnd.ch += increment; return curEnd; }, moveToCharacter: function(cm, motionArgs) { var repeat = motionArgs.repeat; return moveToCharacter(cm, repeat, motionArgs.forward, motionArgs.selectedCharacter); }, moveToColumn: function(cm, motionArgs, vim) { var repeat = motionArgs.repeat; // repeat is equivalent to which column we want to move to! vim.lastHPos = repeat - 1; return moveToColumn(cm, repeat); }, moveToEol: function(cm, motionArgs, vim) { var cur = cm.getCursor(); vim.lastHPos = Infinity; return { line: cur.line + motionArgs.repeat - 1, ch: Infinity }; }, moveToFirstNonWhiteSpaceCharacter: function(cm) { // Go to the start of the line where the text begins, or the end for // whitespace-only lines var cursor = cm.getCursor(); var line = cm.getLine(cursor.line); return { line: cursor.line, ch: findFirstNonWhiteSpaceCharacter(cm.getLine(cursor.line)) }; }, moveToMatchedSymbol: function(cm, motionArgs) { var cursor = cm.getCursor(); var symbol = cm.getLine(cursor.line).charAt(cursor.ch); if (isMatchableSymbol(symbol)) { return findMatchedSymbol(cm, cm.getCursor(), motionArgs.symbol); } else { return cursor; } }, moveToStartOfLine: function(cm) { var cursor = cm.getCursor(); return { line: cursor.line, ch: 0 }; }, moveToLineOrEdgeOfDocument: function(cm, motionArgs) { var lineNum = motionArgs.forward ? cm.lineCount() - 1 : 0; if (motionArgs.repeatIsExplicit) { lineNum = motionArgs.repeat - 1; } return { line: lineNum, ch: findFirstNonWhiteSpaceCharacter(cm.getLine(lineNum)) }; }, textObjectManipulation: function(cm, motionArgs) { var character = motionArgs.selectedCharacter; // Inclusive is the difference between a and i // TODO: Instead of using the additional text object map to perform text // object operations, merge the map into the defaultKeyMap and use // motionArgs to define behavior. Define separate entries for 'aw', // 'iw', 'a[', 'i[', etc. var inclusive = !motionArgs.textObjectInner; if (!textObjects[character]) { // No text object defined for this, don't move. return null; } var tmp = textObjects[character](cm, inclusive); var start = tmp.start; var end = tmp.end; return [start, end]; } }; var operators = { change: function(cm, operatorArgs, vim, curStart, curEnd) { getVimGlobalState().registerController.pushText( operatorArgs.registerName, 'change', cm.getRange(curStart, curEnd), operatorArgs.linewise); if (operatorArgs.linewise) { // Delete starting at the first nonwhitespace character of the first // line, instead of from the start of the first line. This way we get // an indent when we get into insert mode. This behavior isn't quite // correct because we should treat this as a completely new line, and // indent should be whatever codemirror thinks is the right indent. // But cm.indentLine doesn't seem work on empty lines. // TODO: Fix the above. curStart.ch = findFirstNonWhiteSpaceCharacter(cm.getLine(curStart.line)); // Insert an additional newline so that insert mode can start there. // curEnd should be on the first character of the new line. cm.replaceRange('\n', curStart, curEnd); } else { cm.replaceRange('', curStart, curEnd); } cm.setCursor(curStart); }, // delete is a javascript keyword. 'delete': function(cm, operatorArgs, vim, curStart, curEnd) { getVimGlobalState().registerController.pushText( operatorArgs.registerName, 'delete', cm.getRange(curStart, curEnd), operatorArgs.linewise); cm.replaceRange('', curStart, curEnd); if (operatorArgs.linewise) { cm.setCursor(motions.moveToFirstNonWhiteSpaceCharacter(cm)); } else { cm.setCursor(curStart); } }, indent: function(cm, operatorArgs, vim, curStart, curEnd) { var startLine = curStart.line; var endLine = curEnd.line; // In visual mode, n> shifts the selection right n times, instead of // shifting n lines right once. var repeat = (vim.visualMode) ? operatorArgs.repeat : 1; if (operatorArgs.linewise) { // The only way to delete a newline is to delete until the start of // the next line, so in linewise mode evalInput will include the next // line. We don't want this in indent, so we go back a line. endLine--; } for (var i = startLine; i <= endLine; i++) { for (var j = 0; j < repeat; j++) { cm.indentLine(i, operatorArgs.indentRight); } } cm.setCursor(curStart); cm.setCursor(motions.moveToFirstNonWhiteSpaceCharacter(cm)); }, swapcase: function(cm, operatorArgs, vim, curStart, curEnd, curOriginal) { var toSwap = cm.getRange(curStart, curEnd); var swapped = ''; for (var i = 0; i < toSwap.length; i++) { var character = toSwap.charAt(i); swapped += isUpperCase(character) ? character.toLowerCase() : character.toUpperCase(); } cm.replaceRange(swapped, curStart, curEnd); cm.setCursor(curOriginal); }, yank: function(cm, operatorArgs, vim, curStart, curEnd, curOriginal) { getVimGlobalState().registerController.pushText( operatorArgs.registerName, 'yank', cm.getRange(curStart, curEnd), operatorArgs.linewise); cm.setCursor(curOriginal); } }; var actions = { clearSearchHighlight: clearSearchHighlight, enterInsertMode: function(cm, actionArgs) { var insertAt = (actionArgs) ? actionArgs.insertAt : null; if (insertAt == 'eol') { var cursor = cm.getCursor(); cursor = { line: cursor.line, ch: lineLength(cm, cursor.line) }; cm.setCursor(cursor); } else if (insertAt == 'charAfter') { cm.setCursor(offsetCursor(cm.getCursor(), 0, 1)); } cm.setOption('keyMap', 'vim-insert'); }, toggleVisualMode: function(cm, actionArgs, vim) { var repeat = actionArgs.repeat; var curStart = cm.getCursor(); var curEnd; // TODO: The repeat should actually select number of characters/lines // equal to the repeat times the size of the previous visual // operation. if (!vim.visualMode) { vim.visualMode = true; vim.visualLine = !!actionArgs.linewise; if (vim.visualLine) { curStart.ch = 0; curEnd = clipCursorToContent(cm, { line: curStart.line + repeat - 1, ch: lineLength(cm, curStart.line) }, true /** includeLineBreak */); } else { curEnd = clipCursorToContent(cm, { line: curStart.line, ch: curStart.ch + repeat }, true /** includeLineBreak */); } // Make the initial selection. if (!actionArgs.repeatIsExplicit && !vim.visualLine) { // This is a strange case. Here the implicit repeat is 1. The // following commands lets the cursor hover over the 1 character // selection. cm.setCursor(curEnd); cm.setSelection(curEnd, curStart); } else { cm.setSelection(curStart, curEnd); } } else { if (!vim.visualLine && actionArgs.linewise) { // Shift-V pressed in characterwise visual mode. Switch to linewise // visual mode instead of exiting visual mode. vim.visualLine = true; curStart = cm.getCursor('anchor'); curEnd = cm.getCursor('head'); curStart.ch = cursorIsBefore(curStart, curEnd) ? 0 : lineLength(cm, curStart.line); curEnd.ch = cursorIsBefore(curStart, curEnd) ? lineLength(cm, curEnd.line) : 0; cm.setSelection(curStart, curEnd); } else { exitVisualMode(cm, vim); } } }, joinLines: function(cm, actionArgs, vim) { var curStart, curEnd; if (vim.visualMode) { curStart = cm.getCursor('anchor'); curEnd = cm.getCursor('head'); curEnd.ch = lineLength(cm, curEnd.line) - 1; } else { // Repeat is the number of lines to join. Minimum 2 lines. var repeat = Math.max(actionArgs.repeat, 2); curStart = cm.getCursor(); curEnd = clipCursorToContent(cm, { line: curStart.line + repeat - 1, ch: Infinity }); } var finalCh = 0; cm.operation(function() { for (var i = curStart.line; i < curEnd.line; i++) { finalCh = lineLength(cm, curStart.line); var tmp = { line: curStart.line + 1, ch: lineLength(cm, curStart.line + 1) }; var text = cm.getRange(curStart, tmp); text = text.replace(/\n\s*/g, ' '); cm.replaceRange(text, curStart, tmp); } var curFinalPos = { line: curStart.line, ch: finalCh }; cm.setCursor(curFinalPos); }); }, newLineAndEnterInsertMode: function(cm, actionArgs) { var insertAt = cm.getCursor(); if (insertAt.line === 0 && !actionArgs.after) { // Special case for inserting newline before start of document. cm.replaceRange('\n', { line: 0, ch: 0 }); cm.setCursor(0, 0); } else { insertAt.line = (actionArgs.after) ? insertAt.line : insertAt.line - 1; insertAt.ch = lineLength(cm, insertAt.line); cm.setCursor(insertAt); var newlineFn = CodeMirror.commands.newlineAndIndentContinueComment || CodeMirror.commands.newlineAndIndent; newlineFn(cm); } this.enterInsertMode(cm); }, paste: function(cm, actionArgs, vim) { var cur = cm.getCursor(); var register = getVimGlobalState().registerController.getRegister( actionArgs.registerName); if (!register.text) { return; } for (var text = '', i = 0; i < actionArgs.repeat; i++) { text += register.text; } var linewise = register.linewise; if (linewise) { if (actionArgs.after) { // Move the newline at the end to the start instead, and paste just // before the newline character of the line we are on right now. text = '\n' + text.slice(0, text.length - 1); cur.ch = lineLength(cm, cur.line); } else { cur.ch = 0; } } else { cur.ch += actionArgs.after ? 1 : 0; } cm.replaceRange(text, cur); // Now fine tune the cursor to where we want it. var curPosFinal; var idx; if (linewise && actionArgs.after) { curPosFinal = { line: cur.line + 1, ch: findFirstNonWhiteSpaceCharacter(cm.getLine(cur.line + 1)) }; } else if (linewise && !actionArgs.after) { curPosFinal = { line: cur.line, ch: findFirstNonWhiteSpaceCharacter(cm.getLine(cur.line)) }; } else if (!linewise && actionArgs.after) { idx = cm.indexFromPos(cur); curPosFinal = cm.posFromIndex(idx + text.length - 1); } else { idx = cm.indexFromPos(cur); curPosFinal = cm.posFromIndex(idx + text.length); } cm.setCursor(curPosFinal); }, undo: function(cm, actionArgs) { repeatFn(cm, CodeMirror.commands.undo, actionArgs.repeat)(); }, redo: function(cm, actionArgs) { repeatFn(cm, CodeMirror.commands.redo, actionArgs.repeat)(); }, setRegister: function(cm, actionArgs, vim) { vim.inputState.registerName = actionArgs.selectedCharacter; }, setMark: function(cm, actionArgs, vim) { var markName = actionArgs.selectedCharacter; if (!inArray(markName, validMarks)) { return; } if (vim.marks[markName]) { vim.marks[markName].clear(); } vim.marks[markName] = cm.setBookmark(cm.getCursor()); }, replace: function(cm, actionArgs) { var replaceWith = actionArgs.selectedCharacter; var curStart = cm.getCursor(); var line = cm.getLine(curStart.line); var replaceTo = curStart.ch + actionArgs.repeat; if (replaceTo > line.length) { return; } var curEnd = { line: curStart.line, ch: replaceTo }; var replaceWithStr = ''; for (var i = 0; i < curEnd.ch - curStart.ch; i++) { replaceWithStr += replaceWith; } cm.replaceRange(replaceWithStr, curStart, curEnd); cm.setCursor(offsetCursor(curEnd, 0, -1)); } }; var textObjects = { // TODO: lots of possible exceptions that can be thrown here. Try da( // outside