UNPKG

wsh.js

Version:

WebSHell provides a toolkit for building bash-like command line consoles for web pages.

724 lines (577 loc) 19.2 kB
/* global window */ import Base from "./base"; import History from "./history"; import KillRing from "./killring"; import keys from "./keys"; class ReadLine extends Base { constructor(config = {}) { super(config); this.history = config.history || new History(config); this.killring = config.killring || new KillRing(config); this.cursor = 0; this.boundToElement = !!config.element; this.element = config.element || window; this.active = false; this.inSearch = false; this.searchMatch; this.lastSearchText = ""; this.text = ""; this.cursor = 0; this.lastCmd; this.completionActive; this.cmdQueue = []; this.suspended = false; this.cmdMap = { complete: this._cmdComplete.bind(this), done: this._cmdDone.bind(this), noop: this._cmdNoOp.bind(this), historyTop: this._cmdHistoryTop.bind(this), historyEnd: this._cmdHistoryEnd.bind(this), historyNext: this._cmdHistoryNext.bind(this), historyPrevious: this._cmdHistoryPrev.bind(this), end: this._cmdEnd.bind(this), home: this._cmdHome.bind(this), left: this._cmdLeft.bind(this), right: this._cmdRight.bind(this), cancel: this._cmdCancel.bind(this), "delete": this._cmdDeleteChar.bind(this), backspace: this._cmdBackspace.bind(this), killEof: this._cmdKillToEOF.bind(this), killWordback: this._cmdKillWordBackward.bind(this), killWordForward: this._cmdKillWordForward.bind(this), yank: this._cmdYank.bind(this), clear: this._cmdClear.bind(this), search: this._cmdReverseSearch.bind(this), wordBack: this._cmdBackwardWord.bind(this), wordForward: this._cmdForwardWord.bind(this), yankRotate: this._cmdRotate.bind(this), esc: this._cmdEsc.bind(this) }; this.keyMap = { "default": {}, "control": {}, "meta": {} }; this.bind(keys.Backspace, "default", "backspace"); this.bind(keys.Tab, "default", "complete"); this.bind(keys.Enter, "default", "done"); this.bind(keys.Escape, "default", "esc"); this.bind(keys.PageUp, "default", "historyTop"); this.bind(keys.PageDown, "default", "historyEnd"); this.bind(keys.End, "default", "end"); this.bind(keys.Home, "default", "home"); this.bind(keys.Left, "default", "left"); this.bind(keys.Up, "default", "historyPrevious"); this.bind(keys.Right, "default", "right"); this.bind(keys.Down, "default", "historyNext"); this.bind(keys.Delete, "default", "delete"); this.bind(keys.CapsLock, "default", "noop"); this.bind(keys.Pause, "default", "noop"); this.bind(keys.Insert, "default", "noop"); this.bind("A", "control", "home"); this.bind("B", "control", "left"); this.bind("C", "control", "cancel"); this.bind("D", "control", "delete"); this.bind("E", "control", "end"); this.bind("F", "control", "right"); this.bind("P", "control", "historyPrevious"); this.bind("N", "control", "historyNext"); this.bind("K", "control", "killEof"); this.bind("Y", "control", "yank"); this.bind("L", "control", "clear"); this.bind("R", "control", "search"); this.bind(keys.Backspace, "meta", "killWordback"); this.bind("B", "meta", "wordBack"); this.bind("D", "meta", "killWordForward"); this.bind("F", "meta", "wordForward"); this.bind("Y", "meta", "yankRotate"); if (this.boundToElement) { this.attach(this.element); } else { this._subscribeToKeys(); } } isActive() { return this.active; } activate() { this.active = true; this._trigger("activate"); } deactivate() { this.active = false; this._trigger("deactivate"); } bind(key, modifier, action) { const cmd = this.cmdMap[action]; this._log(`Bind key ${key} with modifier ${modifier} to action ${action}`); if (!cmd) { return; } if (typeof key === "number") { this.keyMap[modifier || "default"][key] = cmd; } else { this.keyMap[modifier || "default"][key.charCodeAt()] = cmd; this.keyMap[modifier || "default"][key.toLowerCase().charCodeAt()] = cmd; } } unbind(key, modifier = "default") { this._log(`Unbind key ${key} with modifier ${modifier}`); const code = typeof key === "number" ? key : key.charCodeAt(); delete this.keyMap[modifier][code]; } attach(el) { if (this.element) { this.detach(); } this._log("attaching", el); this.element = el; this.boundToElement = true; this._addEvent(this.element, "focus", this.activate.bind(this)); this._addEvent(this.element, "blur", this.deactivate.bind(this)); this._subscribeToKeys(); } detach() { this._this._removeEvent(this.element, "focus", this.activate.bind(this)); this._this._removeEvent(this.element, "blur", this.deactivate.bind(this)); this.element = null; this.boundToElement = false; } getLine() { return { text: this.text, cursor: this.cursor }; } setLine(line) { this.text = line.text; this.cursor = line.cursor; this._refresh(); } _addEvent(element, name, callback) { if (element.addEventListener) { element.addEventListener(name, callback, false); } else if (element.attachEvent) { element.attachEvent(`on${name}`, callback); } } _removeEvent(element, name, callback) { if (element.removeEventListener) { element.removeEventListener(name, callback, false); } else if (element.detachEvent) { element.detachEvent(`on${name}`, callback); } } _getKeyInfo(e) { const code = e.keyCode || e.charCode; const c = String.fromCharCode(code); return { code: code, character: c, shift: e.shiftKey, ctrl: e.ctrlKey && !e.altKey, alt: e.altKey && !e.ctrlKey, meta: e.metaKey, isChar: true }; } _queue(cmd) { if (this.suspended) { this.cmdQueue.push(cmd); return; } this._call(cmd); } _call(cmd) { this._log(`calling: ${cmd.name}, previous: ${this.lastCmd}`); if (this.inSearch && cmd.name !== "cmdKeyPress" && cmd.name !== "cmdReverseSearch") { this.inSearch = false; if (cmd.name === "cmdEsc") { this.searchMatch = null; } if (this.searchMatch) { if (this.searchMatch.text) { this.cursor = this.searchMatch.cursoridx; this.text = this.searchMatch.text; this.history.applySearch(); } this.searchMatch = null; } this._trigger("searchEnd"); } if (!this.inSearch && this.killring.isinkill() && cmd.name.substr(0, 7) !== "cmdKill") { this.killring.commit(); } this.lastCmd = cmd.name; cmd(); } _suspend(asyncCall) { this.suspended = true; asyncCall(this._resume.bind(this)); } _resume() { const cmd = this.cmdQueue.shift(); if (!cmd) { this.suspended = false; return; } this._call(cmd); this._resume(); } _cmdNoOp() { // no-op, used for keys we capture and ignore } _cmdEsc() { // no-op, only has an effect on reverse search and that action was taken in this._call() } _cmdBackspace() { if (this.cursor === 0) { return; } --this.cursor; this.text = this._remove(this.text, this.cursor, this.cursor + 1); this._refresh(); } _cmdComplete() { if (!this.eventHandlers.completion) { return; } this._suspend((resumeCallback) => { this._trigger("completion", this.getLine()).then((completion) => { if (completion) { this.text = this._insert(this.text, this.cursor, completion); this._updateCursor(this.cursor + completion.length); } this.completionActive = true; resumeCallback(); }); }); } _cmdDone() { const text = this.text; this.history.accept(text); this.text = ""; this.cursor = 0; if (!this.eventHandlers.enter) { return; } this._suspend((resumeCallback) => { this._trigger("enter", text).then((text) => { if (text) { this.text = text; this.cursor = this.text.length; } this._trigger("change", this.getLine()); resumeCallback(); }); }); } _cmdEnd() { this._updateCursor(this.text.length); } _cmdHome() { this._updateCursor(0); } _cmdLeft() { if (this.cursor === 0) { return; } this._updateCursor(this.cursor - 1); } _cmdRight() { if (this.cursor === this.text.length) { return; } this._updateCursor(this.cursor + 1); } _cmdBackwardWord() { if (this.cursor === 0) { return; } this._updateCursor(this._findBeginningOfPreviousWord()); } _cmdForwardWord() { if (this.cursor === this.text.length) { return; } this._updateCursor(this._findEndOfCurrentWord()); } _cmdHistoryPrev() { if (!this.history.hasPrev()) { return; } this._getHistory(this.history.prev.bind(this.history)); } _cmdHistoryNext() { if (!this.history.hasNext()) { return; } this._getHistory(this.history.next.bind(this.history)); } _cmdHistoryTop() { this._getHistory(this.history.top.bind(this.history)); } _cmdHistoryEnd() { this._getHistory(this.history.end.bind(this.history)); } _cmdDeleteChar() { if (this.text.length === 0) { if (this.eventHandlers.eot) { return this._trigger("eot"); } } if (this.cursor === this.text.length) { return; } this.text = this._remove(this.text, this.cursor, this.cursor + 1); this._refresh(); } _cmdCancel() { this._trigger("cancel"); } _cmdKillToEOF() { this.killring.append(this.text.substr(this.cursor)); this.text = this.text.substr(0, this.cursor); this._refresh(); } _cmdKillWordForward() { if (this.text.length === 0) { return; } if (this.cursor === this.text.length) { return; } const end = this._findEndOfCurrentWord(); if (end === this.text.length - 1) { return this._cmdKillToEOF(); } this.killring.append(this.text.substring(this.cursor, end)); this.text = this._remove(this.text, this.cursor, end); this._refresh(); } _cmdKillWordBackward() { if (this.cursor === 0) { return; } const oldCursor = this.cursor; this.cursor = this._findBeginningOfPreviousWord(); this.killring.prepend(this.text.substring(this.cursor, oldCursor)); this.text = this._remove(this.text, this.cursor, oldCursor); this._refresh(); } _cmdYank() { const yank = this.killring.yank(); if (!yank) { return; } this.text = this._insert(this.text, this.cursor, yank); this._updateCursor(this.cursor + yank.length); } _cmdRotate() { const lastyanklength = this.killring.lastyanklength(); if (!lastyanklength) { return; } const yank = this.killring.rotate(); if (!yank) { return; } const oldCursor = this.cursor; this.cursor = this.cursor - lastyanklength; this.text = this._remove(this.text, this.cursor, oldCursor); this.text = this._insert(this.text, this.cursor, yank); this._updateCursor(this.cursor + yank.length); } _cmdClear() { if (this.eventHandlers.clear) { this._trigger("clear"); } else { this._refresh(); } } _cmdReverseSearch() { if (!this.inSearch) { this.inSearch = true; this._trigger("searchStart"); this._trigger("searchChange", {}); } else { if (!this.searchMatch) { this.searchMatch = { term: "" }; } this._search(); } } _updateCursor(position) { this.cursor = position; this._refresh(); } _addText(text) { this.text = this._insert(this.text, this.cursor, text); this.cursor += text.length; this._refresh(); } _addSearchText(text) { if (!this.searchMatch) { this.searchMatch = { term: "" }; } this.searchMatch.term += text; this._search(); } _search() { this._log(`searchtext: ${this.searchMatch.term}`); const match = this.history.search(this.searchMatch.term); if (match !== null) { this.searchMatch = match; this._log(`match: ${match}`); this._trigger("searchChange", match); } } _refresh() { this._trigger("change", this.getLine()); } _getHistory(historyCall) { this.history.update(this.text); this.text = historyCall(); this._updateCursor(this.text.length); } _findBeginningOfPreviousWord() { const position = this.cursor - 1; if (position < 0) { return 0; } let word = false; for (let i = position; i > 0; i--) { const word2 = this._isWordChar(this.text[i]); if (word && !word2) { return i + 1; } word = word2; } return 0; } _findEndOfCurrentWord() { if (this.text.length === 0) { return 0; } const position = this.cursor + 1; if (position >= this.text.length) { return this.text.length - 1; } let word = false; for (let i = position; i < this.text.length; i++) { const word2 = this._isWordChar(this.text[i]); if (word && !word2) { return i; } word = word2; } return this.text.length - 1; } _isWordChar(c) { if (!c) { return false; } const code = c.charCodeAt(0); return (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122); } _remove(text, from, to) { if (text.length <= 1 || text.length <= to - from) { return ""; } if (from === 0) { // delete leading characters return text.substr(to); } const left = text.substr(0, from); const right = text.substr(to); return left + right; } _insert(text, idx, ins) { if (idx === 0) { return ins + text; } if (idx >= text.length) { return text + ins; } const left = text.substr(0, idx); const right = text.substr(idx); return left + ins + right; } _subscribeToKeys() { // set up key capture this._addEvent(this.element, "keydown", (e) => { const key = this._getKeyInfo(e); // return as unhandled if we're not active or the key is just a modifier key if (!this.active || key.code === keys.Shift || key.code === keys.Ctrl || key.code === keys.Alt || key.code === keys.LeftWindowKey) { return true; } // check for some special first keys, regardless of modifiers this._log(`key: ${key.code}`); let cmd = this.keyMap.default[key.code]; // intercept ctrl- and meta- sequences (may override the non-modifier cmd captured above let mod; if (key.ctrl && !key.shift && !key.alt && !key.meta) { mod = this.keyMap.control[key.code]; if (mod) { cmd = mod; } } else if ((key.alt || key.meta) && !key.ctrl && !key.shift) { mod = this.keyMap.meta[key.code]; if (mod) { cmd = mod; } } if (!cmd) { return true; } this._queue(cmd); e.preventDefault(); e.stopPropagation(); e.cancelBubble = true; return false; }); this._addEvent(this.element, "keypress", (e) => { if (!this.active) { return true; } const key = this._getKeyInfo(e); if (key.code === 0 || e.defaultPrevented || key.meta || key.alt || key.ctrl) { return false; } this._queue(() => { if (this.inSearch) { this._addSearchText(key.character); } else { this._addText(key.character); } }); e.preventDefault(); e.stopPropagation(); e.cancelBubble = true; return false; }); this._addEvent(this.element, "paste", (e) => { if (!this.active) { return true; } let pastedText = ""; if (window.clipboardData && window.clipboardData.getData) { pastedText = window.clipboardData.getData("Text"); } else if (e.clipboardData && e.clipboardData.getData) { pastedText = e.clipboardData.getData("text/plain"); } this._queue(() => { if (this.inSearch) { this._addSearchText(pastedText); } else { this._addText(pastedText); } }); e.preventDefault(); e.stopPropagation(); e.cancelBubble = true; return false; }); } } export default ReadLine;