From ab8eb70608654ff00cc1c8d3daa0edc79ab50914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Smodi=C5=A1?= Date: Mon, 31 Aug 2015 23:23:49 +0200 Subject: Implemented the CodeQ Terminal component, alpha quality (to be tested and debugged). --- js/codeq/console.js | 662 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 662 insertions(+) create mode 100644 js/codeq/console.js (limited to 'js') diff --git a/js/codeq/console.js b/js/codeq/console.js new file mode 100644 index 0000000..7dba8e1 --- /dev/null +++ b/js/codeq/console.js @@ -0,0 +1,662 @@ +/** + * 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.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 - 1)); + 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 + '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 + + '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) { + // we add to the end, so optimize: do not call splice(), it is too heavy for this purpose + var newLines = formNewLines(text), + startRow = currentRow, + i, lineDescriptor; + if (newLines.length == 0) return; // nothing to do + lineDescriptor = lines[currentRow]; + lineDescriptor.content = lineDescriptor.content + newLines[0]; + renderLine(lineDescriptor); + for (i = 1; i < newLines.length; i++) { + renderLine(this.insertRow(startRow + i, newLines[i], className, true)); + } + }, + + 'moveLeft': function () { + if (currentCol > 0) { + 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; + if ((currentCol > 0) && (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; + // 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; + } + }, + + handleInput = function (chars) { + var cookedChars = chars, // default + startingRow = currentRow, + endingRow, i; + + if (inputDisabled) { + // flatly ignore + clipboardPasteInProgress = false; + return; + } + + // first cook the input + if (typeof handler.onKeypress === 'function') { + try { + cookedChars = handler.onKeypress(chars, clipboardPasteInProgress || !aKeyIsDown); + } + catch (e) { + // TODO: log error + } + } + + if (!cookedChars) { + // nothing to append + clipboardPasteInProgress = false; + return; + } + + // now serve the cooking + handler.append(cookedChars, 'input'); // append what we have to the display + if (typeof handler.onInput === 'function') { // now tell the owner what we've done + if (lineBuffered) { + if (currentRow !== startingRow) { + // in line-buffered mode emit each line separately, except for the last (current) line + for (i = startingRow; i < endingRow; i++) { + try { + handler.onInput(lines[i].content); + } + catch (e) { + // TODO: log error + } + } + } + } + else { + try { + handler.onInput(cookedChars); + } + catch (e) { + // TODO: log error + } + } + } + + 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 + 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, // 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 + handleInput(evt.input); + }); + } + 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 = false; + if ('char' in evt) keyChar = evt.char; + else if ('charCode' in evt) keyChar = String.fromCharCode(evt.charCode); + else return; // ignore input + handleInput(keyChar); + }); + } + + // emit greeting if provided + if (options.greeting) { + lines.push({content: options.greeting, className: 'greeting'}); + currentRow++; + } + lines.push({content: '', className: 'input'}); // initial input line + render(); + + return handler; + }; +})(); \ No newline at end of file -- cgit v1.2.1