/** * Takes a div element and runs a console simulator within. */ (function () { var noop = function () { return false; }, // ignore keypress defaultKeyMappings = { // keyed by modifiers, each either 1 or 0: shift || ctrl || alt '000': { 'ArrowLeft': function () { this.moveLeft(); return false; }, 'ArrowRight': function () { this.moveRight(); return false; }, 'ArrowUp': noop, 'ArrowDown': noop, 'Backspace': function () { this.deleteCharacterLeft(); return false; }, 'Delete': function () { this.deleteCharacterRight(); return false; }, 'End': function () { this.moveToEndOfLine(); return false; }, 'Home': function () { this.moveToStartOfLine(); return false; }, 'Enter': function () { return true; }, // if it returns false in line-buffered mode, then a line will never be formed -> no input ever 'Tab': noop } }; // Conversion table from legacy key codes for assorted keys // For details see: // * https://w3c.github.io/uievents/ // * http://www.w3.org/TR/2015/WD-DOM-Level-3-Events-code-20150428/ // * http://www.w3.org/TR/2015/WD-DOM-Level-3-Events-key-20150427/ // and their references. var legacyKeyConversion = { 0x03: {'code': 'Cancel'}, 0x06: {'code': 'Help'}, 0x08: {'code': 'Backspace'}, 0x09: {'code': 'Tab'}, 0x0c: {'code': 'Clear'}, 0x0d: {'code': 'Enter'}, 0x13: {'code': 'Pause'}, 0x1b: {'code': 'Escape'}, 0x20: {'code': 'Space', 'key': ' '}, 0x21: {'code': 'PageUp'}, 0x22: {'code': 'PageDown'}, 0x23: {'code': 'End'}, 0x24: {'code': 'Home'}, 0x25: {'code': 'ArrowLeft'}, 0x26: {'code': 'ArrowUp'}, 0x27: {'code': 'ArrowRight'}, 0x28: {'code': 'ArrowDown'}, 0x2c: {'code': 'PrintScreen'}, 0x2d: {'code': 'Insert'}, 0x2e: {'code': 'Delete'}, 0x6a: {'code': 'NumpadMultiply', 'key': '*'}, 0x6b: {'code': 'NumpadAdd', 'key': '+'}, 0x6c: {'code': 'NumpadComma', 'key': ','}, 0x6d: {'code': 'NumpadSubtract', 'key': '-'}, 0x6e: {'code': 'NumpadDecimal', 'key': '.'}, 0x6f: {'code': 'NumpadDivide', 'key': '/'}, 0x90: {'code': 'NumLock'}, 0x91: {'code': 'ScrollLock'}, 0xba: {'code': 'Semicolon', 'key': ';'}, 0xbb: {'code': 'Equal', 'key': '='}, 0xbc: {'code': 'Comma', 'key': ','}, 0xbd: {'code': 'Minus', 'key': '-'}, 0xbe: {'code': 'Period', 'key': '.'}, 0xbf: {'code': 'Slash', 'key': '/'}, 0xc0: {'code': 'Backquote', 'key': '`'}, 0xdb: {'code': 'BracketLeft', 'key': '['}, 0xdc: {'code': 'Backslash', 'key': '\\'}, 0xdd: {'code': 'BracketRight', 'key': ']'}, 0xde: {'code': 'Quote', 'key': "'"} }; // generate the rest of the mappings (function (table) { var userAgent = navigator.userAgent, i; // digits for (i = 0; i < 10; i++) table[0x30 + i] = {'code': 'Digit' + i, 'key': '' + i}; // characters: 'key' is the lower-cased character for (i = 0; i < 26; i++) table[0x41 + i] = {'code': 'Key' + String.fromCharCode(0x41 + i), 'key': String.fromCharCode(0x61 + i)}; // numpad for (i = 0; i < 10; i++) table[0x60 + i] = {'code': 'Numpad' + i, 'key': '' + i}; // function keys F1 to F24 for (i = 1; i < 25; i++) table[0x6f + i] = {'code': 'F' + i}; // override tables if (userAgent.indexOf('Gecko/') >= 0) { // Firefox table[0x3b] = {'code': 'Semicolon', 'key': ';'}; table[0x3d] = {'code': 'Equal', 'key': '='}; table[0x6b] = {'code': 'Equal', 'key': '='}; table[0x6d] = {'code': 'Minus', 'key': '-'}; table[0xbb] = {'code': 'NumpadAdd', 'key': '+'}; table[0xbd] = {'code': 'NumpadSubtract', 'key': '-'}; if (navigator.platform.indexOf('Mac') >= 0) { table[0x0c] = {'code': 'NumLock'}; table[0xad] = {'code': 'Minus', 'key': '-'}; } else if (navigator.platform.indexOf('Win') >= 0) { table[0xad] = {'code': 'Minus', 'key': '-'}; } } if (navigator.vendor && (navigator.vendor.indexOf('Apple') >= 0)) { // Safari table[0x03] = {'code': 'Enter'}; table[0x19] = {'code': 'Tab'}; } if ((userAgent.indexOf('iPad') >= 0) || (userAgent.indexOf('iPod') >= 0) || (userAgent.indexOf('iPhone') >= 0)) { // ios table[0x0a] = {'code': 'Enter'}; } })(legacyKeyConversion); // some (older) browsers specify incorrect key codes, this is a fix-up mapping var codeFixTable = { 'Esc': 'Escape', 'Left': 'ArrowLeft', 'Up': 'ArrowUp', 'Right': 'ArrowRight', 'Down': 'ArrowDown', 'Del': 'Delete' }; /** * Takes the given DOM Keyboard Event and returns a normalized representation. * @param event */ var processKeyboardEvent = function (event) { var keyCode, converted, result, c; if (('code' in event) && (('key' in event) || ('keyIdentifier') in event)) { // we have DOM3 KeyboardEvent, no conversion needed result = {'code': codeFixTable[event.code] || event.code, 'key': event.key || event.keyIdentifier}; } else { keyCode = event.keyCode || event.which; converted = legacyKeyConversion[keyCode]; if (converted) { result = {'code': converted.code}; if (converted.key) result.key = converted.key; else result.key = converted.code; // as per DOM3 spec: unprintables have the key the same as the code TODO: not true for all of them, e.g. AltLeft -> Alt } else { // unknown key, try to set at least some info result = {'code': ''}; c = String.fromCharCode(keyCode); if (c !== '') result.key = c; else result.key = 'Unidentified'; } } // char is set only in keypress events, never in keydown and keyup // (keypress is not a part of any standard) if ('char' in event) { result.char = event.char; } else if ('charCode' in event) { result.char = String.fromCharCode(event.charCode); } else if (event.type == 'keypress') { // TODO: generate result.char somehow? Probably not needed, we should use the input event instead of the keypress event anyway. } return result; }; codeq.makeConsole = function (jqElt, options) { var lines = [], // console content maxLines = (options && options.maxLines) || 10000, // how many lines to display at most currentRow = 0, // cursor position currentCol = 0, jqContent = $('
'), // the rendering of the console jqInput = $(''), // for receiving keypresses, clipboard content inputDisabled = false, // whether to ignore input lineBuffered = true, // whether we are line-buffered or not buffered renderLine = function (lineDescriptor) { var jqLine = lineDescriptor.jqLine, className = 'cq-con-text ' + lineDescriptor.className, content = lineDescriptor.content || '', jq1, jq2, jqCursor; if (!jqLine) { jqLine = $('
'); if (lineDescriptor.row == 0) { jqContent.prepend(jqLine); } else if ((lineDescriptor.row < lines.length) && lines[lineDescriptor.row - 1].jqLine) { lines[lineDescriptor.row - 1].jqLine.after(jqLine); } else { // bail-out line, we should never end up here jqContent.append(jqLine); } lineDescriptor.jqLine = jqLine; } else { jqLine.empty(); } if (currentRow == lineDescriptor.row) { // mark the cursor in the current line if (currentCol >= content.length) { jq1 = $(''); jqCursor = $(' '); jqLine.append(jq1); jqLine.append(jqCursor); jq1.text(content); } else if (currentCol <= 0) { jq2 = $(''); jqCursor = $(''); jqLine.append(jqCursor); jqLine.append(jq2); jqCursor.text(content.charAt(0)); jq2.text(content.substr(1)); } else { jq1 = $(''); jq2 = $(''); jqCursor = $(''); jqLine.append(jq1); jqLine.append(jqCursor); jqLine.append(jq2); jq1.text(content.substr(0, currentCol)); jqCursor.text(content.charAt(currentCol)); jq2.text(content.substr(currentCol + 1)); } } else { jq1 = $(''); jqLine.append(jq1); jq1.text(lineDescriptor.content); } }, render = function () { var i, lineDescriptor, n = lines.length; jqContent.empty(); for (i = 0; i < n; i++) { lineDescriptor = lines[i]; lineDescriptor.row = i; if (lineDescriptor.jqLine) { lineDescriptor.jqLine.remove(); lineDescriptor.jqLine = null; } renderLine(lineDescriptor); } }, formNewLines = function (newText) { var newLines, newTextLines; if (!newText) newLines = []; else if (newText instanceof Array) { newLines = []; for (i = 0; i < newText.length; i++) { newTextLines = ('' + newText[i]).split('\n'); for (j = 0; j < newTextLines.length; j++) newLines.push(newTextLines[j]); } } else { newLines = ('' + newText).split('\n'); } return newLines; }, // the handler object that is returned handler = { 'setLineBuffered': function () { lineBuffered = true; }, 'setNotBuffered': function () { lineBuffered = false; }, 'onInput': false, // the caller should assign a function here, that takes input as parameter, and optionally returns a promise which is then waited to be resolved before further input is accepted 'onKeypress': false, // same, but it takes a keycode, and returns a keycode 'inputEnable': function () { inputDisabled = false; }, 'inputDisable': function () { inputDisabled = true; }, 'keyMaps': {}, // will be filled in later 'leftmostCol': 0, // the column from where editing is possible; it is not possible to delete or change a character to the left of this column 'reflow': function () { var startRow, endRow, i, lineDescriptor; if (arguments.length == 0) { // no arguments, reflow all lines startRow = 0; endRow = lines.length - 1; } else if (arguments.length == 1) { // only the starting row given, reflow all lines from there on startRow = arguments[0]; endRow = lines.length - 1; } else { // both arguments are given startRow = arguments[0]; endRow = arguments[1]; } for (i = startRow; i <= endRow; i++) { lineDescriptor = lines[i]; lineDescriptor.row = i; renderLine(lineDescriptor); } }, 'removeRow': function (row, deferReflow) { var lineDescriptor; if (row >= lines.length) return; // already absent lineDescriptor = lines[row]; lines.splice(row, 1); if (lineDescriptor.jqLine) { lineDescriptor.jqLine.remove(); lineDescriptor.jqLine = null; } if (!deferReflow) this.reflow(row); return lineDescriptor; }, 'insertRow': function (row, content, className, deferReflow) { // inserts a new row at the position specified var lineDescriptor, i, jqLine; for (i = lines.length; i < row; i++) { // insert any empty rows lineDescriptor = {content: '', className: className, row: i}; lines.push(lineDescriptor); if (!deferReflow) renderLine(lineDescriptor); } lineDescriptor = {content: content, className: className, row: row}; if (row >= lines.length) lines.push(lineDescriptor); else lines.splice(row, 0, lineDescriptor); if (!deferReflow) this.reflow(row); return lineDescriptor; }, 'splice': function (startRow, startCol, endRow, endCol, newText, className) { var i, j, n, startLine, endLine, newLines, newTextLines, part1, part2; if (!className) className = 'output'; // pre-process newText, convert it to newLines: an array of strings without newlines newLines = formNewLines(newText); if (newLines.length == 0) newLines.push(''); // there must at least be an empty "glue" if (lines.length > 0) { // there is something to remove // verify sanity if (startRow < 0) startRow = 0; else if (startRow >= lines.length) startRow = lines.length - 1; if (endRow < 0) endRow = 0; else if (endRow >= lines.length) endRow = lines.length - 1; if (endRow < startRow) { // swap the meaning of the start and end coordinates i = startRow; startRow = endRow; endRow = i; i = startCol; startCol = endCol; endCol = i; } if (startRow == endRow) { if (endCol < startCol) { i = startCol; startCol = endCol; endCol = i; } } startLine = lines[startRow]; endLine = lines[endRow]; if (startCol < 0) startCol = 0; else if (startCol > startLine.length) startCol = startLine.length; if (endCol < 0) endCol = 0; else if (endCol > endLine.length) endCol = endLine.length; // execute splice part1 = startLine.content.substring(0, startCol); // the remaining content on the first and last lines part2 = endLine.content.substring(endCol); // modify the first and last lines, and make the list of the old lines to remove j = startRow + 1; // where to start removing lines n = endRow - startRow; // how many lines to remove if (newLines.length > 1) { // the first and the last new lines are separate existing lines, modify them startLine.content = part1 + newLines[0]; if (startRow == endRow) { // we need to create one additional line to hold the compositum of the last line and part2 this.insertRow(j, newLines[newLines.length - 1] + part2, className, true); j++; } else { endLine.content = newLines[newLines.length - 1] + part2; n--; // retain the last line } } else { // the first and the last new lines are the same existing line startLine.content = part1 + newLines[0] + part2; } // remove the rest of the old lines while (n > 0) { this.removeRow(j, true); n--; } // insert new lines in between j = newLines.length - 1; // the first and the last lines have already been set up for (i = 1; i < j; i++) { this.insertRow(startRow + i, newLines[i], className, true); } // optimization step: if only one line changed, then only reflow the line if ((n == 0) && (newLines.length == 1)) { renderLine(startLine); } else { this.reflow(startRow); } } else { // no content exists, add all lines for (i = 0; i < newLines.length; i++) { this.insertRow(i, newLines[i], className, true); } this.reflow(); } }, 'insert': function (text, row, col, className) { this.splice(row, col, row, col, text, className || 'output'); }, 'append': function (text, className, dontMoveCursor) { // we add to the end, so optimize: do not call splice(), it is too heavy for this purpose var newLines = formNewLines(text), originalCurrentRow = currentRow, startRow = lines.length - 1, i, lineDescriptor; if (newLines.length == 0) return; // nothing to do lineDescriptor = lines[startRow]; lineDescriptor.content = lineDescriptor.content + newLines[0]; for (i = 1; i < newLines.length; i++) { lineDescriptor = this.insertRow(startRow + i, newLines[i], className, true); } if (!dontMoveCursor) { currentRow = lines.length - 1; currentCol = lines[currentRow].content.length; } this.reflow(startRow < originalCurrentRow ? startRow : originalCurrentRow); }, 'insertAtCursor': function (text, className, dontMoveCursor) { var startRow = currentRow, currentLine = lines[currentRow], currentContent = currentLine.content, part1 = currentContent.substring(0, currentCol), part2 = currentContent.substring(currentCol), newLines = formNewLines(text), i, n; if (newLines.length == 0) return; // nothing to do else if (newLines.length == 1) { currentLine.content = part1 + newLines[0] + part2; if (!dontMoveCursor) currentCol += newLines[0].length; renderLine(currentLine); } else { currentLine.content = part1 + newLines[0]; n = newLines.length - 1; for (i = 1; i < n; i++) { this.insertRow(startRow + i, newLines[i], className, true); } this.insertRow(startRow + n, newLines[n] + part2, className, true); if (!dontMoveCursor) { currentRow = startRow + n; currentCol = newLines[n].length; } this.reflow(startRow); } }, 'moveLeft': function () { var leftmost = typeof this.leftmostCol === 'number' && this.leftmostCol || 0; if (currentCol > leftmost) { currentCol--; renderLine(lines[currentRow]); } }, 'moveRight': function () { var lineDescriptor = lines[currentRow]; if (currentCol < lineDescriptor.content.length) { currentCol++; renderLine(lineDescriptor); } }, 'deleteCharacterLeft': function () { var lineDescriptor = lines[currentRow], content = lineDescriptor.content, leftmost = typeof this.leftmostCol === 'number' && this.leftmostCol || 0; if ((currentCol > leftmost) && (currentCol <= content.length)) { currentCol--; lineDescriptor.content = content.substring(0, currentCol) + content.substring(currentCol + 1); renderLine(lineDescriptor); } }, 'deleteCharacterRight': function () { var lineDescriptor = lines[currentRow], content = lineDescriptor.content; if ((currentCol >= 0) && (currentCol < content.length)) { lineDescriptor.content = content.substring(0, currentCol) + content.substring(currentCol + 1); renderLine(lineDescriptor); } }, 'moveToEndOfLine': function () { var lineDescriptor = lines[currentRow], n = lineDescriptor.content.length; if (currentCol != n) { currentCol = n; renderLine(lineDescriptor); } }, 'moveToStartOfLine': function () { var lineDescriptor = lines[currentRow]; if (currentCol != 0) { currentCol = 0; renderLine(lineDescriptor); } }, 'destroy': function () { var n = lines.length, i, lineDescriptor; if (blinkTimer !== null) { clearInterval(blinkTimer); blinkTimer = null; } // detach all jQuery objects from internal structures for (i = 0; i < n; i++) { lineDescriptor = lines[i]; if (lineDescriptor.jqLine) { lineDescriptor.jqLine.remove(); lineDescriptor.jqLine = null; } } lines.length = 0; // clear and detach the rest render = null; renderLine = null; jqContent.remove(); jqContent = null; jqInput.remove(); jqInput = null; for (i in handler) { if (handler.hasOwnProperty(i)) { delete handler[i]; } } jqElt.empty(); jqElt = null; options = null; } }, internallyDisabled = false, handleInput = function (chars) { var isPastedFromClipboard = clipboardPasteInProgress || !aKeyIsDown, newLines = chars.split('\n'), N = newLines.length, lastLineIndex = N - 1, i, promise; if (inputDisabled || internallyDisabled) { // flatly ignore clipboardPasteInProgress = false; return; } internallyDisabled = true; promise = Q(); for (i = 0; i < N; i++) { promise = promise .then((function (newLine, lineIndex) {return function () { var cookedChars = lineIndex < lastLineIndex ? newLine + '\n' : newLine, // default thisRow = currentRow, leftmost = typeof this.leftmostCol === 'number' && this.leftmostCol || 0, dh; // first cook the input if (typeof handler.onKeypress === 'function') { // only invoke the handler if the newLine is not an empty string, otherwise cookedChars = '' try { cookedChars = cookedChars && handler.onKeypress(cookedChars, isPastedFromClipboard); } catch (e) { codeq.log.error('Error during invocation of terminal onKeypress: ' + e, e); } } if (!cookedChars) { // nothing to append return; } // now serve the cooking handler.insertAtCursor(cookedChars, 'input'); // append what we have to the display // scroll to bottom on input dh = jqContent.height() - jqElt.height(); if (dh > 0) jqElt.scrollTop(dh); if (typeof handler.onInput === 'function') { // now tell the owner what we've done if (lineBuffered) { if (thisRow < currentRow) { // in line-buffered mode emit each line separately, except for the last (current) line try { return handler.onInput(lines[thisRow].content.substring(leftmost)); } catch (e) { codeq.log.error('Error while invoking terminal onInput: ' + e, e); } } } else if (cookedChars) { try { return handler.onInput(cookedChars); } catch (e) { codeq.log.error('Error while invoking terminal onInput: ' + e, e); } } } };})(newLines[i], i)) .fail(function (e) { codeq.log.error('Error in handleInput loop: ' + e, e); }); } promise.fin(function () { internallyDisabled = false; }).done(); clipboardPasteInProgress = false; }, clipboardPasteInProgress = false, // whether the previous keydown was a CTRL+V or shift+insert aKeyIsDown = false, // whether a key is currently pressed: if it's not and there is input, then we got a paste via mouse cursorBlinkRate = (options && options.cursorBlinkRate) || 750, // default: 750 ms blinkTimer = null, i, j, a, b, c; // copy default key handlers a = handler.keyMaps; for (i in defaultKeyMappings) { if (!defaultKeyMappings.hasOwnProperty(i)) continue; b = defaultKeyMappings[i]; c = {}; a[i] = c; for (j in b) { if (!b.hasOwnProperty(j)) continue; c[j] = b[j]; } } a = null; b = null; c = null; // initialize the DOM container jqElt.empty(); jqElt.append(jqContent); jqElt.append(jqInput); jqElt.on('click', function () { // set focus to the textarea so we pick up keypresses jqInput.css('position', 'fixed').focus(); }); jqElt.on('blur', function () { jqInput.css('position', 'absolute'); }); // register key handlers jqInput.on('keydown', function (evt) { var modifiers = ['' + (0 + evt.shiftKey), '' + (0 + (evt.ctrlKey || evt.metaKey)), '' + (0 + evt.altKey)].join(''), handlersForModifiers = handler.keyMaps[modifiers], acceptKeydown = !(inputDisabled || internallyDisabled), // the default value eventInfo = processKeyboardEvent(evt), handlerForKeydown = handlersForModifiers && handlersForModifiers[eventInfo.code]; if (acceptKeydown && (typeof handlerForKeydown === 'function')) { try { acceptKeydown = handlerForKeydown.apply(handler, [eventInfo]); } catch (e) { // ignore errors, TODO: log errors } } if (!acceptKeydown) { evt.preventDefault(); evt.stopPropagation(); return false; } // paste: CTRL+V or shift+insert clipboardPasteInProgress = ((modifiers === '010') && (eventInfo.code === 'KeyV')) || ((modifiers === '100') && (eventInfo.code === 'Insert')); aKeyIsDown = true; return true; }); jqInput.on('keyup', function (evt) { aKeyIsDown = false; }); if ('oninput' in jqInput[0]) { // the element supports input events, use them in preference to keypress events jqInput.on('input', function (evt) { // this event cannot be prevented/cancelled in the DOM scheme of things, but we can simulate prevention, because we have a "detached" display var t = jqInput.val(); jqInput.val(''); handleInput(t); }); } else { // the element does not support the "modern" input events, so it ought to support the old keypress events jqInput.on('keypress', function (evt) { // this event cannot be prevented/cancelled in the DOM scheme of things, but we can simulate prevention, because we have a "detached" display var keyChar; try { if ('char' in evt) keyChar = evt.char; else if ('charCode' in evt) keyChar = String.fromCharCode(evt.charCode); else return; // ignore input handleInput(keyChar); } finally { jqInput.val(''); } }); } // start the cursor blinking if so instructed if ((typeof cursorBlinkRate === 'number') && (cursorBlinkRate >= 50)) { // have some sense of sanity blinkTimer = setInterval(function () { jqContent.find('.cq-con-cursor').toggleClass('inverted'); }, cursorBlinkRate); } // emit greeting if provided if (options && options.greeting) { lines.push({content: options.greeting, className: 'greeting'}); currentRow++; } lines.push({content: '', className: 'input'}); // initial input line render(); return handler; }; })();