UNPKG

espruino-web-ide

Version:

A Terminal and Graphical code Editor for Espruino JavaScript Microcontrollers

678 lines (625 loc) 26.3 kB
/** Copyright 2014 Gordon Williams (gw@pur3.co.uk) This Source Code is subject to the terms of the Mozilla Public License, v2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. ------------------------------------------------------------------ VT100 terminal window ------------------------------------------------------------------ **/ "use strict"; (function(){ if(typeof process !== "undefined" && !process.versions.nw && !process.versions.electron) return; // don't load this in std NodeJS var onInputData = function(d){}; // the handler for character data from user var displayTimeout = null; var displayData = []; // Text to be displayed in the terminal var termText = [ "" ]; // Map of terminal line number to text to display before it var termExtraText = {}; // List of (jquerified) DOM elements for each line var elements = []; var termCursorX = 0; var termCursorY = 0; var termControlChars = []; // maximum lines on the terminal var MAX_LINES = 2048; function init() { // Add buttons if (Espruino.Core.App) Espruino.Core.App.addIcon({ id: "clearScreen", icon: "clear", title : "Clear Screen", order: -100, area: { name: "terminal", position: "top" }, click: function(){ clearTerminal(); focus(); } }); // Add stuff we need $('<div id="terminal" class="terminal"></div>').appendTo(".editor--terminal .editor__canvas"); $('<textarea id="terminalfocus" class="terminal__focus" rows="1" cols="1"></textarea>').appendTo(document.body); var terminal = document.getElementById("terminal"); var terminalfocus = document.getElementById("terminalfocus"); var html; if (Espruino.Core.Terminal.OVERRIDE_CONTENTS) { html = Espruino.Core.Terminal.OVERRIDE_CONTENTS; } else { html = ` <div style="max-width:400px;margin:auto;"> <p><a href="http://www.espruino.com/Web+IDE" target="_blank"><img src="img/ide_logo.png" width="299" height="96" alt="Espruino IDE"/></a></p> <p id="versioninfo" style="text-align:right"></p> <p style="text-align:center;font-weight:bold">A Code Editor and Terminal for <a href="http://www.espruino.com" target="_blank">Espruino JavaScript Microcontrollers</a></p> <p>Try the <a class="tour_link" href="#">guided tour</a> and <a href="http://www.espruino.com/Quick+Start" target="_blank">getting started</a> guide for more information, tutorials and example projects.</p> <div id="terminalnews"></div> <p>Espruino is <a href="https://github.com/espruino" target="_blank">Open Source</a>. Please support us by <a href="http://www.espruino.com/Donate" target="_blank">donating</a> or <a href="http://www.espruino.com/Order" target="_blank">buying an official board</a>.</p> <p style="text-align:right"> <a href="http://twitter.com/Espruino" target="_blank"><img src="img/icon_twitter.png" width="16" height="16" alt="Follow on Twitter"/></a> <a href="http://youtube.com/subscription_center?add_user=espruino" target="_blank"><img src="img/icon_youtube.png" width="44" height="16" alt="Subscribe on YouTube"/></a> <a href="https://www.patreon.com/espruino" target="_blank"><img src="img/icon_patreon.png" width="45" height="16" alt="Support on Patreon"/></a> </p> </div>`; Espruino.Core.Utils.getVersionInfo(function(v) { $("#versioninfo").html(v); var r = 0|(Math.random()*1000000); $.get("https://www.espruino.com/ide/news.html?v="+encodeURIComponent(v.replace(/[ ,]/g,"")+"&r="+r), function (data){ $("#terminalnews").html(data); }); }); } terminal.innerHTML = html; $(".tour_link").click(function(e) { e.preventDefault(); $("#icon-tour").click(); }); var mouseDownTime = Date.now(); var mouseUpTime = Date.now(); window.addEventListener("mousedown", function() { mouseDownTime = Date.now(); }); terminal.addEventListener("mouseup" , function(e) { var selection = window.getSelection(); var shortClick = Date.now() < mouseDownTime+200; var doubleClick = Date.now() < mouseUpTime+600; mouseUpTime = Date.now(); /* Maybe we basically just clicked (<200ms) in which case we don't want to copy but just move the cursor. DON'T move cursor for double-clicks */ if (shortClick && !doubleClick) { // Move cursor, if we can... if (selection && selection.baseNode && selection.baseNode.parentNode && selection.baseNode.parentNode.className=="termLine") { var cx = selection.baseOffset; var cy = selection.baseNode.parentNode.attributes.linenumber.value; var prev = selection.baseNode.previousSibling; while (prev) { cx += prev.textContent.length; prev = prev.previousSibling; } //console.log("Click to ",cx,cy, termCursorX,termCursorY); var s = ""; var tx = termCursorX; var ty = termCursorY; while (cx<tx) { tx--; s+=String.fromCharCode(27,91,68); } // left while (cy>ty && termText[ty+1] && ":>".indexOf(termText[ty+1][0])>=0) { ty++; s+=String.fromCharCode(27,91,66); } while (cy<ty && termText[ty-1] && ":>".indexOf(termText[ty-1][0])>=0) { ty--; s+=String.fromCharCode(27,91,65); } if (!termText[ty]) cx=0; else if (termText[ty].length<cx) cx = termText[ty].length; while (cx>tx) { tx++; s+=String.fromCharCode(27,91,67); } // right if (s.length) { if (termCursorY==termText.length-1 && termCursorX==termText[termCursorY].length) { if (termCursorX<=1) { /* if we're right at the end, but there are no characters so we can't step back - don't try and move because we can't */ s=""; } else { /* if we're at the end of the last line, we need to step left then move, then right - or we could just end up going back in the command history */ s = String.fromCharCode(27,91,68) + s + String.fromCharCode(27,91,67); } } if (s.length) onInputData(s); } } terminalfocus.focus(); window.scrollTo(0,0); // as terminalfocus is offscreen, just in case force us back onscreen return; } /* this rather convoluted code checks to see if the selection * is actually part of the terminal. It may be that the user * clicked on the editor pane, dragged, and released over the * terminal in which case we DON'T want to copy. */ if (selection.rangeCount > 0) { var node = selection.getRangeAt(0).startContainer; while (node && node!=terminal) node = node.parentNode; if (node==terminal) { // selection WAS part of terminal var selectedText = selection.toString(); if (selectedText.trim().length > 0) { //console.log(selectedText); //console.log(selectedText.split("").map(function(c) { return c.charCodeAt(0); })); selectedText = selectedText.replace(/\xA0/g," "); // Convert nbsp chars to spaces //console.log(selectedText.split("").map(function(c) { return c.charCodeAt(0); })); /* Because Espruino prefixes multi-line code with ':' it makes it a nightmare to copy/paste. This hack gets around it. */ var allColon = true, hasNewline = false; var trimmedSelectedText = selectedText.trim(); for (var i=0;i<trimmedSelectedText.length-1;i++) { if (trimmedSelectedText[i]=="\n") hasNewline = true; if (trimmedSelectedText[i]=="\n" && trimmedSelectedText[i+1]!=":") allColon = false; } if (allColon && hasNewline) { selectedText = selectedText.replace(/\n:/g,"\n"); if (selectedText[0]==">" || selectedText[1]==":") selectedText = selectedText.substr(1); } terminalfocus.value = selectedText; terminalfocus.select(); document.execCommand('copy'); terminalfocus.value = ''; lastValue = ''; } } } terminalfocus.focus(); }); terminalfocus.focus(); terminalfocus.addEventListener("focus", function() { terminal.classList.add('focus'); }); terminalfocus.addEventListener("blur", function() { terminal.classList.remove('focus'); }); /* Super hack for Android. We can't just look at keypresses since it wants to do autocomplete. What we do is keep the current word (at least until someone presses a special char) in an input box and then try and send the characters needed to keep text on Espruino up to date with the text box. */ var lastValue = terminalfocus.value; function changeListener() { var thisValue = terminalfocus.value; var commonChars = 0; while (commonChars<thisValue.length && commonChars<lastValue.length && thisValue[commonChars] == lastValue[commonChars]) commonChars++; var text = ""; for (var i=commonChars;i<lastValue.length;i++) text+="\x08"; // backspace text+=thisValue.substr(commonChars); lastValue = terminalfocus.value; if (text.length) onInputData(Espruino.Core.Utils.fixBrokenCode(text)); } terminalfocus.addEventListener("input", changeListener); terminalfocus.addEventListener("keydown", function(e) { var ch = undefined; if (e.keyCode == 13) ch = String.fromCharCode(13); if (e.ctrlKey) { if (e.keyCode == 'C'.charCodeAt(0)) ch = String.fromCharCode(3); // control C if (e.keyCode == 'F'.charCodeAt(0)) { // fullscreen e.preventDefault(); var term = document.querySelector(".editor__canvas__terminal"); if (term.classList.contains("editor__canvas__fullscreen")) { // was fullscreen - make windowed term.classList.remove("editor__canvas__fullscreen"); document.querySelector(".editor--terminal").append(term) } else { term.classList.add("editor__canvas__fullscreen"); document.body.append(term); } // if we have a webcam it seems we need to start it playing again // after moving it var vid = document.querySelector("video"); if (vid) vid.play(); } } if (e.altKey) { if (e.keyCode == 13) ch = String.fromCharCode(27,10); // Alt enter } if (e.keyCode == 8) ch = "\x08"; // backspace if (e.keyCode == 9) ch = "\x09"; // tab if (e.keyCode == 46) ch = String.fromCharCode(27,91,51,126); // delete if (e.keyCode == 38) ch = String.fromCharCode(27,91,65); // up if (e.keyCode == 40) ch = String.fromCharCode(27,91,66); // down if (e.keyCode == 39) ch = String.fromCharCode(27,91,67); // right if (e.keyCode == 37) ch = String.fromCharCode(27,91,68); // left if (e.keyCode == 36) ch = String.fromCharCode(27,79,72); // home if (e.keyCode == 35) ch = String.fromCharCode(27,79,70); // end if (e.keyCode == 33) ch = String.fromCharCode(27,91,53,126); // page up if (e.keyCode == 34) ch = String.fromCharCode(27,91,54,126); // page down if (ch!=undefined) { e.preventDefault(); terminalfocus.value = ""; lastValue = ""; onInputData(ch); } }); terminalfocus.addEventListener("paste", function() { // nasty hack - wait for paste to complete, then get contents of input setTimeout(function () { changeListener(); terminalfocus.value = ""; lastValue = ""; }, 100); }); // Ensure that data from Espruino goes to this terminal Espruino.Core.Serial.startListening(Espruino.Core.Terminal.outputDataHandler); Espruino.addProcessor("connected", function(data, callback) { grabSerialPort(); terminal.classList.add("terminal--connected"); callback(data); }); Espruino.addProcessor("disconnected", function(data, callback) { // carriage return, clear to right - remove prompt, add newline - outputDataHandler("\n"); terminal.classList.remove("terminal--connected"); callback(data); }); Espruino.addProcessor("notification", function(data, callback) { var elementClass = "terminal-status-"+data.type; var line = termCursorY; if (!termExtraText[line]) termExtraText[line]=""; termExtraText[line] += '<div class="terminal-status-container"><div class="terminal-status '+elementClass+'">'+data.msg+'</div></div>'; updateTerminal(); callback(data); }); }; /// send the given characters as if they were typed var typeCharacters = function(s) { onInputData(s); } var clearTerminal = function() { // Get just the last entered line var currentLine = Espruino.Core.Terminal.getInputLine(); if (currentLine==undefined) currentLine = { text : "", line : 0 }; termText = currentLine.text.split("\n"); // re-add > and : marks for (var l in termText) termText[l] = (l==0?">":":") + termText[l]; // reset other stuff... termExtraText = {}; // leave X cursor where it was... termCursorY -= currentLine.line; // move Y cursor back termControlChars = []; // finally update the HTML updateTerminal(); // fire off a clear terminal processor Espruino.callProcessor("terminalClear"); }; var updateTerminal = function() { var terminal = $("#terminal"); // gather a list of elements for each line elements = []; terminal.children().each(function() { var n = $(this).attr("lineNumber"); if (n!==undefined) elements[n] = $(this); else $(this).remove(); // remove stuff that doesn't have a line number }); // remove extra lines if there are too many if (termText.length > MAX_LINES) { var removedLines = termText.length - MAX_LINES; termText = termText.slice(removedLines); termCursorY -= removedLines; var newTermExtraText = {}; for (var i in termExtraText) { if (i>=removedLines) newTermExtraText[i-removedLines] = termExtraText[i]; } termExtraText = newTermExtraText; // now renumber our elements (cycle them around) var newElements = []; for (i in elements) { var n = elements[i].attr("lineNumber") - removedLines; if (n<0) { // if it's fallen off the bottom, delete it elements[i].remove(); } else { elements[i].attr("lineNumber", n); newElements[n] = elements[i]; } } elements = newElements; } // remove elements if we have too many... for (i=termText.length;i<elements.length;i++) if (i in elements) elements[i].remove(); // now write this to the screen var t = []; for (var y in termText) { var line = termText[y]; if (y == termCursorY) { var ch = Espruino.Core.Utils.getSubString(line,termCursorX,1); line = Espruino.Core.Utils.escapeHTML( Espruino.Core.Utils.getSubString(line,0,termCursorX)) + "<span class='terminal__cursor'>" + Espruino.Core.Utils.escapeHTML(ch) + "</span>" + Espruino.Core.Utils.escapeHTML(Espruino.Core.Utils.getSubString(line,termCursorX+1)); } else { line = Espruino.Core.Utils.escapeHTML(line); // handle URLs line = line.replace(/(https?:\/\/[-a-zA-Z0-9@:%._\+~#=\/\?]+)/g, '<a href="$1" target="_blank">$1</a>'); } // detect inline images and link them in var m = line.match(/data:image\/\w+;base64,[\w\+\/=]+/); if (m) { line = line.substr(0,m.index)+'<a href="'+m[0]+'" download><img class="terminal-inline-image" src="'+m[0]+'"/></a>'+line.substr(m.index+m[0].length); } // extra text is for stuff like tutorials if (termExtraText[y]) line = termExtraText[y] + line; // Only update the elements if they need updating if (elements[y]===undefined) { var prev = y-1; while (prev>=0 && elements[prev]===undefined) prev--; elements[y] = $("<div class='termLine' lineNumber='"+y+"'>"+line+"</div>"); if (prev<0) elements[y].appendTo(terminal); else elements[y].insertAfter(elements[prev]); } else if (elements[y].html()!=line) elements[y].html(line); } // now show the line where the cursor is if (elements[termCursorY]!==undefined) { terminal[0].scrollTop = elements[termCursorY][0].offsetTop; } /* Move input box to the same place as the cursor, so Android devices keep that part of the screen in view */ var cursor = document.getElementsByClassName("terminal__cursor"); if (cursor.length) { var pos = cursor[0].getBoundingClientRect(); var terminalfocus = document.getElementById("terminalfocus"); var x = Math.min(pos.left, terminal.offsetWidth); terminalfocus.style.left=x+"px"; terminalfocus.style.top=pos.top+"px"; terminalfocus.style["z-index"]=-100; } }; function trimRight(str) { var s = str.length-1; while (s>0 && str[s]==" ") s--; return str.substr(0,s+1); } var handleReceivedCharacter = function (/*char*/ch) { //console.log("IN = "+ch); if (termControlChars.length==0) { switch (ch) { case 8 : { if (termCursorX>0) termCursorX--; } break; case 10 : { // line feed Espruino.callProcessor("terminalNewLine", termText[termCursorY]); termCursorX = 0; termCursorY++; while (termCursorY >= termText.length) termText.push(""); } break; case 13 : { // carriage return termCursorX = 0; } break; case 27 : { termControlChars = [ 27 ]; } break; case 19 : break; // XOFF case 17 : break; // XON case 0xC2 : break; // UTF8 for <255 - ignore this default : { // Else actually add character if (termText[termCursorY]===undefined) termText[termCursorY]=""; termText[termCursorY] = trimRight( Espruino.Core.Utils.getSubString(termText[termCursorY],0,termCursorX) + String.fromCharCode(ch) + Espruino.Core.Utils.getSubString(termText[termCursorY],termCursorX+1)); termCursorX++; // check for the 'prompt', eg '>' or 'debug>' // if we have it, send a 'terminalPrompt' message if (ch == ">".charCodeAt(0)) { var prompt = termText[termCursorY]; if (prompt==">" || prompt=="debug>") Espruino.callProcessor("terminalPrompt", prompt); } } } } else if (termControlChars[0]==27) { // Esc if (termControlChars[1]==91) { // Esc [ if (termControlChars[2]==63) { if (termControlChars[3]==55) { if (ch!=108) console.log("Expected 27, 91, 63, 55, 108 - no line overflow sequence"); termControlChars = []; } else { if (ch==55) { termControlChars = [27, 91, 63, 55]; } else termControlChars = []; } } else { termControlChars = []; switch (ch) { case 63: termControlChars = [27, 91, 63]; break; case 65: if (termCursorY > 0) termCursorY--; break; // up FIXME should add extra lines in... case 66: termCursorY++; while (termCursorY >= termText.length) termText.push(""); break; // down FIXME should add extra lines in... case 67: termCursorX++; break; // right case 68: if (termCursorX > 0) termCursorX--; break; // left case 74: termText[termCursorY] = termText[termCursorY].substr(0,termCursorX); // Delete to right + down termText = termText.slice(0,termCursorY+1); break; case 75: termText[termCursorY] = termText[termCursorY].substr(0,termCursorX); break; // Delete to right } } } else { switch (ch) { case 91: { termControlChars = [27, 91]; } break; default: { termControlChars = []; } } } } else termControlChars = []; }; // ---------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------- /// Set the callback(String) that gets called when the user presses a key. Returns the old one function setInputDataHandler( callback ) { var old = onInputData; onInputData = callback; return old; }; /// Called when data comes OUT of Espruino INTO the terminal function outputDataHandler(readData) { if ("string" == typeof readData) readData = readData.split("").map(function(x) {return x.charCodeAt();}); // Add data to our buffer var bufView=new Uint8Array(readData); searchData(bufView); for (var i=0;i<bufView.length;i++) displayData.push(bufView[i]); // If we haven't had data after 50ms, update the HTML if (displayTimeout == null) displayTimeout = window.setTimeout(function() { for (i in displayData) handleReceivedCharacter(displayData[i]); updateTerminal(); displayData = []; displayTimeout = null; }, 50); }; var receivedData = ""; function searchData(bytes){ var si,ei; for(var i = 0; i < bytes.length; i++) { receivedData += String.fromCharCode(bytes[i]); } si = receivedData.indexOf("<<<<<"); if(si >= 0){ receivedData = receivedData.substr(si); ei = receivedData.indexOf(">>>>>"); if(ei > 0){ receivedData = receivedData.substr(5,ei - 5); Espruino.callProcessor("getWatched",receivedData,function(){}); receivedData = ""; } } else{ receivedData = ""; } } /// Claim input and output of the Serial port function grabSerialPort() { // Ensure that keypresses go direct to the Espruino device Espruino.Core.Terminal.setInputDataHandler(function(d) { Espruino.Core.Serial.write(d); }); // Ensure that data from Espruino goes to this terminal Espruino.Core.Serial.startListening(Espruino.Core.Terminal.outputDataHandler); }; /// Get the current terminal line that we're on function getCurrentLine() { return termText.length-1; }; /// Set extra text to display before a certain terminal line function setExtraText(line, text) { if (termExtraText[line] != text) { termExtraText[line] = text; updateTerminal(); } }; /// Clear all extra text that is to be displayed function clearExtraText() { termExtraText = {}; updateTerminal(); }; /// Does the terminal have focus? function hasFocus() { return document.querySelector("#terminal").classList.contains("focus"); }; /// Give the terminal focus function focus() { $("#terminalfocus").focus(); }; // Is the terminal actually visible, or is it so small it can't be seen? function isVisible() { return ($("#terminal").width() > 20) && ($("#terminal").height() > 20); } /** Get the Nth from latest terminal line (and the line number of it). 0=current line. * By terminal line we mean a line starting with '>' */ function getInputLine(n) { if (n===undefined) n=0; var startLine = termText.length-1; while (startLine>=0 && !(n==0 && termText[startLine].substr(0,1)==">")) { if (termText[startLine].substr(0,1)==">") n--; startLine--; } if (startLine<0) return undefined; var line = startLine; var text = termText[line++].substr(1); while (line < termText.length && termText[line].substr(0,1)==":") text += "\n"+termText[line++].substr(1); return { line : startLine, text : text }; }; /** Get the Nth from latest line of text in the terminal (unlike getInputLine) */ function getTerminalLine(n) { if (n===undefined) n=0; var line = termText.length-(1+n); if (line<0) return undefined; return termText[line]; }; /** Add a notification to the terminal (as HTML). If options.buttonclick is set then the first <button> inside the notification text will have a click handler registered*/ function addNotification(text, options) { options = options||{}; var line = getInputLine(0); line = (line===undefined)?0:line.line; if (!termExtraText[line]) termExtraText[line]=""; termExtraText[line] += '<div class="notification_text">'+text+'</div>'; updateTerminal(); if (options.buttonclick) { var btn = elements[line].find("button"); if (!btn.length) console.error("Espruino.Core.Terminal buttonclick set but no button"); btn.on('click', options.buttonclick); } } Espruino.Core.Terminal = { init : init, getInputLine : getInputLine, getTerminalLine : getTerminalLine, getCurrentLine : getCurrentLine, isVisible : isVisible, // Is the terminal actually visible, or is it so small it can't be seen? hasFocus : hasFocus, // Does the termninal have focus? focus : focus, // Give this focus clearTerminal : clearTerminal, // Clear the contents of the terminal setExtraText : setExtraText, clearExtraText : clearExtraText, addNotification : addNotification, // wrapper around setExtraText to add advice to the terminal grabSerialPort : grabSerialPort, setInputDataHandler : setInputDataHandler, outputDataHandler : outputDataHandler, typeCharacters : typeCharacters }; })();