diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..afd05e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules +www/bundle.js +www/bundle.js.gz +www/bundle.js.map +doc +build +.vscode +*~ +.idea +www/glTF-Sample-Models diff --git a/README.md b/README.md new file mode 100644 index 0000000..3fee0fc --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Prototype REPL interface for Calva. + +## Description + +This repo contains a prototype HTML5 REPL control, currently supporting syntax highlighting, selection, keyboard navigation and clipboard support. + +Ultimately it is intended to be a general presentation stream REPL interactor, in the vein of CLIM, Symbolics Open Genera and the earlier MIT CONS and CADR, with a modern twist. It is not directly tied to any language, so eventually it should be portable to any language you fancy, including ECMAScript. + +## Presentation What? + +You can think of a *presentation stream* as a stream much like stdout/stdin, but supporting rich user interface controls. Not only can you push text through it, you can push *any* data through it, and custom controls that react to that data are created inline to display and edit it. If you have ever used `Mathematica` or `Gorilla REPL`, you have an idea how this works. + +## Purpose + +Calva doesn't currently support a dedicated REPL display, but uses the builtin VSCode terminal. With exciting new features in Clojure 1.10, such as `datafy` and `nav`, it is now possible to build rich inspectors in Clojure +in the vein of [CLIM](http://web.archive.org/web/20120707045546/http://www.mikemac.com:80/mikemac/clim/cover.html). + +Sadly, VSCode's editor does not support rich HTML embedded in a `vscode.TextDocument`, but the abilty to display interactive graphs, tables, etc. at the REPL is too tempting to pass by. Fortunately VSCode *does* support `WebView`, which allows us to have an embedded html view. + +Thus, this project was born. It is still rather an experiment- since we are an isolated web page within vscode, +we literally need to re-build a syntax highlighting editor, undo/redo support etc from the ground up. + +Currently what we have is a simple console control, but by layering atop this, more shininess can be added. + +## Goals + +* A reusable (not tied to VSCode/Calva) html REPL. +* High performance +* Auto indent +* Paredit +* Completions +* Customizable presentation types + +## Trying it out + +The build is fairly trivial, horay. + +clone this repository, and from within the directory: + +`$ npm i` + +`$ npm run dev` + +Now point your browser at `http://localhost:8080` and type away. The REPL is not yet connected, it is effectively a dumb terminal with syntax highlighting for now. diff --git a/TODO.txt b/TODO.txt new file mode 100644 index 0000000..80cbbe6 --- /dev/null +++ b/TODO.txt @@ -0,0 +1,13 @@ +*Smart update the selection- +* do not change any lines that don't need to be. +*mouse selection +*Delete range on insert. +*clipboard handler +*insertString cursor positioning on paste is wrong +undo <<< +----------------------------------------- +proper re-startable lexer again... + +match-parens + +auto-indent diff --git a/dist/main.js b/dist/main.js new file mode 100644 index 0000000..1363bf9 --- /dev/null +++ b/dist/main.js @@ -0,0 +1 @@ +!function(t){var e={};function s(i){if(e[i])return e[i].exports;var r=e[i]={i:i,l:!1,exports:{}};return t[i].call(r.exports,r,r.exports,s),r.l=!0,r.exports}s.m=t,s.c=e,s.d=function(t,e,i){s.o(t,e)||Object.defineProperty(t,e,{configurable:!1,enumerable:!0,get:i})},s.r=function(t){Object.defineProperty(t,"__esModule",{value:!0})},s.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return s.d(e,"a",e),e},s.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},s.p="",s(s.s=0)}([function(t,e,s){"use strict";s.r(e);class i{constructor(t,e){this.source=t,this.rules=e,this.position=0}peek(){return this.peeked=this.scan()}match(t,e){let s=this.peek();return!(!s||s.type!=t||e&&s.raw!=e)&&(this.peeked=null,!0)}scan(){if(this.peeked){let t=this.peeked;return this.peeked=null,t}var t=null,e=0;if(this.rules.forEach(s=>{s.r.lastIndex=this.position;var i=s.r.exec(this.source);i&&i[0].length>e&&this.position+i[0].length==s.r.lastIndex&&((t=s.fn(this,i)).offset=this.position,t.raw=i[0],e=i[0].length)}),this.position+=e,null==t){if(this.position==this.source.length)return null;throw new Error("Unexpected character at "+this.position+": "+JSON.stringify(this.source))}return t}}let r=new class{constructor(){this.rules=[]}terminal(t,e){this.rules.push({r:new RegExp(t,"g"),fn:e})}lex(t){return new i(t,this.rules)}};r.terminal("[\\s,]+",(t,e)=>({type:"ws"})),r.terminal(";.*",(t,e)=>({type:"comment"})),r.terminal("\\(|\\[|\\{|#\\(|#?\\(|#\\{|#?@\\(",(t,e)=>({type:"open"})),r.terminal("\\)|\\]|\\}",(t,e)=>({type:"close"})),r.terminal("~@|~|'|#'|#:|#_|\\^|`|#|\\^:",(t,e)=>({type:"punc"})),r.terminal("true|false|nil",(t,e)=>({type:"lit"})),r.terminal("[0-9]+[rR][0-9a-zA-Z]+",(t,e)=>({type:"lit"})),r.terminal("[-+]?[0-9]+(\\.[0-9]+)?([eE][-+]?[0-9]+)?",(t,e)=>({type:"lit"})),r.terminal(":[^()[\\]\\{\\}#,~@'`^\"\\s]*",(t,e)=>({type:"kw"})),r.terminal("[^()[\\]\\{\\}#,~@'`^\"\\s:][^()[\\]\\{\\}#,~@'`^\"\\s]*",(t,e)=>({type:"id"})),r.terminal('"([^"\\\\]|\\\\.)*"?',(t,e)=>({type:"str"})),r.terminal(".",(t,e)=>({type:"junk"}));let n=document.createElement("canvas").getContext("2d");function l(t){return n.measureText(t).width}let h=new Set(["if","let","do","while","cond","case"]);function o(t){let e=document.createElement("span"),s=t.type;return"id"==t.type&&(t.raw.startsWith("def")?s="decl":h.has(t.raw)&&(s="macro")),e.textContent=t.raw,e.className=s,e}class a{constructor(t){this.text=t,this.tokens=[]}}class d{constructor(){this.lines=[new a("")],this.changedLines=new Set,this.insertedLines=new Set,this.deletedLines=new Set}getOffsetForLine(t){let e=0;for(let s=0;sthis.lines[e].text.length))return[e,t];t-=this.lines[e].text.length+1}return[this.lines.length-1,this.lines[this.lines.length-1].text.length]}insertString(t,e){let[s,i]=this.getRowCol(t),r=e.split(/\r\n|\n/),n=0;if(1==r.length)this.lines[s].text=this.lines[s].text.substring(0,i)+e+this.lines[s].text.substring(i),n+=e.length;else{let t=this.lines[s].text.substring(i);this.lines[s].text=this.lines[s].text.substring(0,i)+r[0];let e=[];for(let t=1;t0?this.getRowCol(t):this.getRowCol(t+e),[r,n]=e>0?this.getRowCol(t+e):this.getRowCol(t);if(r!=s){let t=this.lines[s].text.substring(0,i),e=this.lines[r].text.substring(n);this.lines[s].text=t+e,this.lines.splice(s+1,r-s),this.changedLines.add(s),this.deletedLines.add([s+1,r-s])}else this.lines[s].text=this.lines[s].text.substring(0,i)+this.lines[s].text.substring(i+e),this.changedLines.add(s)}get maxOffset(){let t=0;for(let e=0;e{if(1==t.key.length)c.insertString(t.key);else switch(t.keyCode){case 9:t.preventDefault();break;case 13:c.insertString("\n");break;case 37:c.caretLeft(!t.shiftKey);break;case 39:c.caretRight(!t.shiftKey);break;case 8:c.backspace();break;case 36:t.ctrlKey?c.caretHomeAll(!t.shiftKey):c.caretHome(!t.shiftKey);break;case 35:t.ctrlKey?c.caretEndAll(!t.shiftKey):c.caretEnd(!t.shiftKey);break;case 38:c.caretUp(!t.shiftKey);break;case 40:c.caretDown(!t.shiftKey);break;case 46:c.delete()}});let c=new class{constructor(t){this.mainElem=t,this._cursorStart=0,this._cursorEnd=0,this.lastCursorStart=0,this.lastCursorEnd=0,this.model=new d,this.inputLines=[],this.caretX=0,this.mainElem.addEventListener("mousedown",t=>{this.cursorEnd=this.positionToOffset(t.pageX,t.pageY),this.caretX=this.model.getRowCol(this.cursorEnd)[1],this.updateState()}),this.caret=document.createElement("div"),this.caret.className="caret";let e=this.makeLine();this.inputLines.push(e),this.mainElem.appendChild(e),n.font=getComputedStyle(e).font+"",this.caret.style.width=l("M")+"px",e.appendChild(this.caret)}get cursorStart(){return this._cursorStart}set cursorStart(t){this._cursorStart=Math.min(this.model.maxOffset,Math.max(t,0))}get cursorEnd(){return this._cursorEnd}set cursorEnd(t){this._cursorEnd=Math.min(this.model.maxOffset,Math.max(t,0))}insertString(t){this.cursorEnd+=this.model.insertString(this.cursorEnd,t),this.cursorStart=this.cursorEnd,this.updateState(),this.caretX=this.model.getRowCol(this.cursorEnd)[1]}caretLeft(t=!0){this.cursorEnd--,t&&(this.cursorStart=this.cursorEnd),this.updateState(),this.caretX=this.model.getRowCol(this.cursorEnd)[1]}caretRight(t=!0){this.cursorEnd++,t&&(this.cursorStart=this.cursorEnd),this.updateState(),this.caretX=this.model.getRowCol(this.cursorEnd)[1]}caretHomeAll(t=!0){this.cursorEnd=0,t&&(this.cursorStart=this.cursorEnd),this.updateState(),this.caretX=this.model.getRowCol(this.cursorEnd)[1]}caretEndAll(t=!0){this.cursorEnd=this.model.maxOffset,t&&(this.cursorStart=this.cursorEnd),this.updateState(),this.caretX=this.model.getRowCol(this.cursorEnd)[1]}caretHome(t=!0){let[e,s]=this.model.getRowCol(this.cursorEnd);this.cursorEnd=this.cursorEnd-s,t&&(this.cursorStart=this.cursorEnd),this.updateState(),this.caretX=this.model.getRowCol(this.cursorEnd)[1]}caretEnd(t=!0){let[e,s]=this.model.getRowCol(this.cursorEnd);this.cursorEnd=this.cursorEnd-s+this.model.lines[e].text.length,t&&(this.cursorStart=this.cursorEnd),this.updateState()}caretUp(t=!0){let[e,s]=this.model.getRowCol(this.cursorEnd);if(e>0){let t=this.model.lines[e-1].text.length;this.cursorEnd=this.model.getOffsetForLine(e-1)+Math.min(this.caretX,t)}else this.cursorEnd=0;t&&(this.cursorStart=this.cursorEnd),this.updateState()}caretDown(t=!0){let[e,s]=this.model.getRowCol(this.cursorEnd);if(e0&&(this.model.deleteRange(this.cursorEnd-1,1),this.cursorEnd--),this.updateState(),this.caretX=this.model.getRowCol(this.cursorEnd)[1]}delete(){this.model.deleteRange(this.cursorEnd,1),this.updateState(),this.caretX=this.model.getRowCol(this.cursorEnd)[1]}makeSelection(t,e){let s=document.createElement("div");s.className="sel-marker";let i=t;return s.style.left=i+"px",s.style.width=e+"px",s}updateState(){for(let[t,e]of this.model.insertedLines)for(let s=0;sh[0])for(;e.firstChild;)e.removeChild(e.firstChild);else if(t==n[0]&&t==h[0]){for(;e.firstChild;)e.removeChild(e.firstChild);let t=l("M")*n[1];e.appendChild(this.makeSelection(t,l("M")*h[1]-t))}else if(t==n[0]){for(;e.firstChild;)e.removeChild(e.firstChild);let s=l("M")*n[1];e.appendChild(this.makeSelection(s,l("M")*this.model.lines[t].text.length-s))}else if(t==h[0]){for(;e.firstChild;)e.removeChild(e.firstChild);e.appendChild(this.makeSelection(0,l("M")*h[1]))}else if(t>n[0]&&t any +} + +/** + * A Lexer instance, parsing a given file. Usually you should use a LexicalGrammar to + * create one of these. + * + * @class + * @param {string} source the source code to parse + * @param rules the rules of this lexer. + */ +export class Lexer { + position: number = 0; + constructor(public source: string, public rules: Rule[]) { + } + + peeked: any; + + peek() { + return this.peeked = this.scan(); + } + + match(type: string, raw?: string) { + let p = this.peek(); + if(p && p.type == type && (!raw || p.raw == raw)) { + this.peeked = null; + return true; + } + return false; + } + + scan(): Token { + if(this.peeked) { + let res = this.peeked; + this.peeked = null; + return res; + } + var token = null; + var length = 0; + this.rules.forEach(rule => { + rule.r.lastIndex = this.position; + var x = rule.r.exec(this.source); + if (x && x[0].length > length && this.position + x[0].length == rule.r.lastIndex) { + token = rule.fn(this, x); + token.offset = this.position; + token.raw = x[0]; + length = x[0].length; + } + }) + this.position += length; + if (token == null) { + if(this.position == this.source.length) + return null; + throw new Error("Unexpected character at " + this.position + ": "+JSON.stringify(this.source)); + } + return token; + } +} + +/** + * A lexical grammar- factory for lexer instances. + * @class + */ +export class LexicalGrammar { + rules: Rule[] = []; + + /** + * Defines a terminal with the given pattern and constructor. + * @param {string} pattern the pattern this nonterminal must match. + * @param {function(Array): Object} fn returns a lexical token representing + * this terminal. An additional "offset" property containing the token source position + * will also be added, as well as a "raw" property, containing the raw string match. + */ + terminal(pattern: string, fn: (T, RegExpExecArray) => any): void { + this.rules.push({ r: new RegExp(pattern, "g"), fn: fn }) + } + + lex(source: string): Lexer { + return new Lexer(source, this.rules) + } +} \ No newline at end of file diff --git a/src/client/main.ts b/src/client/main.ts new file mode 100644 index 0000000..8118582 --- /dev/null +++ b/src/client/main.ts @@ -0,0 +1,649 @@ +import { LexicalGrammar, Token } from "./lexer" + +let toplevel = new LexicalGrammar() + + +// whitespace +toplevel.terminal("[\\s,]+", (l, m) => ({ type: "ws" })) +// comments +toplevel.terminal(";.*", (l, m) => ({ type: "comment" })) +// open parens +toplevel.terminal("\\(|\\[|\\{|#\\(|#?\\(|#\\{|#?@\\(", (l, m) => ({ type: "open" })) +// close parens +toplevel.terminal("\\)|\\]|\\}", (l, m) => ({ type: "close" })) + +// punctuators +toplevel.terminal("~@|~|'|#'|#:|#_|\\^|`|#|\\^:", (l, m) => ({ type: "punc" })) + +toplevel.terminal("true|false|nil", (l, m) => ({type: "lit"})) +toplevel.terminal("[0-9]+[rR][0-9a-zA-Z]+", (l, m) => ({ type: "lit" })) +toplevel.terminal("[-+]?[0-9]+(\\.[0-9]+)?([eE][-+]?[0-9]+)?", (l, m) => ({ type: "lit" })) + +toplevel.terminal(":[^()[\\]\\{\\}#,~@'`^\"\\s]*", (l, m) => ({ type: "kw" })) +// this is a REALLY lose symbol definition, but similar to how clojure really collects it. numbers/true/nil are all +toplevel.terminal("[^()[\\]\\{\\}#,~@'`^\"\\s:][^()[\\]\\{\\}#,~@'`^\"\\s]*", (l, m) => ({ type: "id" })) +// complete string on a single line +toplevel.terminal('"([^"\\\\]|\\\\.)*"?', (l, m) => ({ type: "str"})) + +toplevel.terminal('.', (l, m) => ({ type: "junk" })) + +const canvas = document.createElement("canvas"); +let ctx = canvas.getContext("2d") as CanvasRenderingContext2D; + +function measureText(str: string) { + return ctx.measureText(str).width; +} + +let macros = new Set(["if", "let", "do", "while", "cond", "case"]); + +function makeToken(tk: Token) { + let span = document.createElement("span"); + let className = tk.type; + if(tk.type == "id") { + if(tk.raw.startsWith("def")) + className = "decl"; + else if(macros.has(tk.raw)) + className = "macro"; + } + + span.textContent = tk.raw; + span.className = className; + return span; +} + +class ReplLine { + tokens: Token[] = []; + constructor(public text: string) { + } +} + +abstract class UndoStep { + name: string; + abstract undo(c: ReplConsole): void; + abstract redo(c: ReplConsole): void; +} + +class EditorUndoStep extends UndoStep { + constructor(public name: string, public selectionStart: [number, number], public selectionEnd: [number, number]) { + super(); + } + + undo(c: ReplConsole) { + [c.cursorStart, c.cursorEnd] = this.selectionStart; + // delete the insertedText + } + + redo(c: ReplConsole) { + [c.cursorStart, c.cursorEnd] = this.selectionEnd; + } +} + +class EditorInsertUndoStep extends EditorUndoStep { + constructor(name: string, selectionStart: [number, number], selectionEnd: [number, number], public offset: number, public insertedText: string) { + super(name, selectionStart, selectionEnd); + } + + undo(c: ReplConsole) { + c.model.deleteRange(this.offset, this.insertedText.length) + super.undo(c); + } + + redo(c: ReplConsole) { + c.model.insertString(this.offset, this.insertedText) + super.redo(c); + } +} + +class EditorDeleteUndoStep extends EditorUndoStep { + constructor(name: string, selectionStart: [number, number], selectionEnd: [number, number], public offset: number, public deletedText: string) { + super(name, selectionStart, selectionEnd); + } + + undo(c: ReplConsole) { + c.model.insertString(this.offset, this.deletedText) + super.undo(c); + } + + redo(c: ReplConsole) { + c.model.deleteRange(this.offset, this.deletedText.length) + super.redo(c); + } +} + +class LineInputModel { + lines: ReplLine[] = [new ReplLine("")]; + changedLines: Set = new Set(); + insertedLines: Set<[number, number]> = new Set(); + deletedLines: Set<[number, number]> = new Set(); + + getOffsetForLine(line: number) { + let max = 0; + for(let i=0; i this.lines[i].text.length) + offset -= this.lines[i].text.length+1; + else + return [i, offset]; + } + return [this.lines.length-1, this.lines[this.lines.length-1].text.length] + } + + insertString(offset: number, text: string): number { + let [row, col] = this.getRowCol(offset); + let lines = text.split(/\r\n|\n/); + let count = 0; + if(lines.length == 1) { + this.lines[row].text = this.lines[row].text.substring(0, col) + text + this.lines[row].text.substring(col); + count += text.length; + } else { + let rhs = this.lines[row].text.substring(col); + this.lines[row].text = this.lines[row].text.substring(0, col) + lines[0]; + let newItems = []; + for(let i=1; i 0 ? this.getRowCol(offset) : this.getRowCol(offset+length); + let [endRow, endCol] = length > 0 ? this.getRowCol(offset+length) : this.getRowCol(offset); + + if(endRow != row) { + let left = this.lines[row].text.substring(0, col); + let right = this.lines[endRow].text.substring(endCol); + this.lines[row].text = left + right; + this.lines.splice(row+1, endRow-row); + this.changedLines.add(row); + this.deletedLines.add([row+1, endRow-row]) + } else { + this.lines[row].text = this.lines[row].text.substring(0, col) + this.lines[row].text.substring(col+length); + this.changedLines.add(row); + } + } + + get maxOffset() { + let max = 0; + for(let i=0; i this.cursorEnd) + this.cursorEnd = this.cursorStart; + else + this.cursorStart = this.cursorEnd; + } else { + this.cursorEnd++ + if(clear) + this.cursorStart = this.cursorEnd; + } + this.updateState(); + this.caretX = this.model.getRowCol(this.cursorEnd)[1]; + } + + caretHomeAll(clear: boolean = true) { + this.cursorEnd = 0; + if(clear) + this.cursorStart = this.cursorEnd; + this.updateState(); + this.caretX = this.model.getRowCol(this.cursorEnd)[1]; + } + + caretEndAll(clear: boolean = true) { + this.cursorEnd = this.model.maxOffset; + if(clear) + this.cursorStart = this.cursorEnd; + this.updateState(); + this.caretX = this.model.getRowCol(this.cursorEnd)[1]; + } + + caretHome(clear: boolean = true) { + let [row, col] = this.model.getRowCol(this.cursorEnd); + this.cursorEnd = this.cursorEnd-col; + if(clear) + this.cursorStart = this.cursorEnd; + this.updateState(); + this.caretX = this.model.getRowCol(this.cursorEnd)[1]; + } + + caretEnd(clear: boolean = true) { + let [row, col] = this.model.getRowCol(this.cursorEnd); + this.cursorEnd = this.cursorEnd-col + this.model.lines[row].text.length; + if(clear) + this.cursorStart = this.cursorEnd; + this.updateState(); + this.caretX = this.model.getRowCol(this.cursorEnd)[1]; + } + + caretUp(clear: boolean = true) { + let [row, col] = this.model.getRowCol(this.cursorEnd); + if(row > 0) { + let len = this.model.lines[row-1].text.length; + this.cursorEnd = this.model.getOffsetForLine(row-1)+Math.min(this.caretX, len); + } else { + this.cursorEnd = 0; + } + if(clear) + this.cursorStart = this.cursorEnd; + this.updateState(); + } + + caretDown(clear: boolean = true) { + let [row, col] = this.model.getRowCol(this.cursorEnd); + if(row < this.model.lines.length-1) { + let len = this.model.lines[row+1].text.length; + this.cursorEnd = this.model.getOffsetForLine(row+1)+Math.min(this.caretX, len); + } else { + this.cursorEnd = this.model.maxOffset; + } + if(clear) + this.cursorStart = this.cursorEnd; + this.updateState(); + } + + private deleteSelection() { + if(this.cursorStart != this.cursorEnd) { + this.model.deleteRange(Math.min(this.cursorStart, this.cursorEnd), Math.max(this.cursorStart, this.cursorEnd)-Math.min(this.cursorStart, this.cursorEnd)); + this.cursorStart = this.cursorEnd = Math.min(this.cursorStart, this.cursorEnd); + } + } + + backspace() { + if(this.cursorStart != this.cursorEnd) { + this.deleteSelection(); + } else { + if(this.cursorEnd > 0) { + this.model.deleteRange(this.cursorEnd-1, 1); + this.cursorEnd--; + } + this.cursorStart = this.cursorEnd; + } + this.updateState() + this.caretX = this.model.getRowCol(this.cursorEnd)[1]; + } + + delete() { + if(this.cursorStart != this.cursorEnd) { + this.deleteSelection(); + } else { + this.model.deleteRange(this.cursorEnd, 1); + this.cursorStart = this.cursorEnd; + } + this.caretX = this.model.getRowCol(this.cursorEnd)[1]; + this.updateState() + } + + private makeSelection(start: number, width: number) { + let div = document.createElement("div") + div.className = "sel-marker"; + let left = start; + div.style.left = left + "px"; + div.style.width = width + "px"; + return div; + } + + updateState() { + // insert any new lines + for(let [start, count] of this.model.insertedLines) { + for(let j=0; j ce[0]) { + // definitely outside the selection, nuke all the selectiond divs. + while(ln.firstChild) + ln.removeChild(ln.firstChild); + } else if(line == cs[0] && line == ce[0]) { + // this selection is exactly 1 line, and we're at it. + while(ln.firstChild) + ln.removeChild(ln.firstChild); + let left = measureText("M")*cs[1]; + ln.appendChild(this.makeSelection(left, measureText("M")*ce[1]-left)); + } else if(line == cs[0]) { + // this is the first line of the selection + while(ln.firstChild) + ln.removeChild(ln.firstChild); + let left = measureText("M")*cs[1]; + ln.appendChild(this.makeSelection(left, measureText("M")*this.model.lines[line].text.length - left)); + } else if(line == ce[0]) { + // this is the last line of the selection + while(ln.firstChild) + ln.removeChild(ln.firstChild); + ln.appendChild(this.makeSelection(0, measureText("M")*ce[1])); + } else if(line > cs[0] && line < ce[0]) { + // this line is within the selection, but is not the first or last. + if(line > lcs[0] && line < lce[0]) { + // this line was within the selection previously, it is already highlighted, + // nothing to do. + } else if(line >= cs[0] && line <= ce[0]) { + // this line is newly within the selection + while(ln.firstChild) + ln.removeChild(ln.firstChild); + ln.appendChild(this.makeSelection(0, Math.max(measureText("M"), measureText("M")*this.model.lines[line].text.length))); + } else { + // this line is no longer within the selection + while(ln.firstChild) + ln.removeChild(ln.firstChild); + } + } + } + + this.lastCursorStart = this.cursorStart; + this.lastCursorEnd = this.cursorEnd; + } + + positionToOffset(pageX: number, pageY: number) { + let rect = this.mainElem.getBoundingClientRect(); + let y = pageY-rect.top; + let i: number; + for(i=0; i { + this.cursorEnd = this.positionToOffset(e.pageX, e.pageY) + this.caretX = this.model.getRowCol(this.cursorEnd)[1]; + this.updateState(); + } + + private mouseUp = (e: MouseEvent) => { + window.removeEventListener("mousemove", this.mouseDrag) + window.removeEventListener("mouseup", this.mouseUp) + } + + constructor(public mainElem: HTMLDivElement) { + this.mainElem.addEventListener("mousedown", e => { + e.preventDefault(); + this.cursorStart = this.cursorEnd = this.positionToOffset(e.pageX, e.pageY) + this.caretX = this.model.getRowCol(this.cursorEnd)[1]; + this.updateState(); + + window.addEventListener("mousemove", this.mouseDrag) + window.addEventListener("mouseup", this.mouseUp) + }) + + this.caret = document.createElement("div"); + this.caret.className = "caret"; + let line = this.makeLine(); + this.inputLines.push(line) + this.mainElem.appendChild(line); + ctx.font = getComputedStyle(line).font+""; + this.caret.style.width = measureText("M")+"px"; + line.appendChild(this.caret); + } + + private makeLine() { + let line = document.createElement("div"); + line.className = "line"; + + let content = document.createElement("div"); + content.className = "content"; + line.append(content); + + let selection = document.createElement("div"); + selection.className = "selection"; + line.append(selection); + return line; + } +} + +window.addEventListener("keydown", e => { + if(e.key.length == 1 && !e.ctrlKey) { + replMain.insertString(e.key); + } else if(e.key.length == 1) { + switch(e.key) { + case "a": + replMain.cursorStart = 0; + replMain.cursorEnd = replMain.model.maxOffset; + replMain.updateState(); + e.preventDefault(); + break; + case 'z': + replMain.undoManager.undo(replMain); + replMain.updateState() + break; + case 'Z': + replMain.undoManager.redo(replMain); + replMain.updateState() + break; + } + } else { + switch(e.keyCode) { + case 9: // Tab + e.preventDefault(); + break; + case 13: + replMain.insertString("\n"); + break; + case 37: // Left arrow + replMain.caretLeft(!e.shiftKey); + break; + case 39: // Right arrow + replMain.caretRight(!e.shiftKey); + break; + case 8: // Backspace + replMain.backspace(); + break; + case 36: // Home + if(e.ctrlKey) + replMain.caretHomeAll(!e.shiftKey); + else + replMain.caretHome(!e.shiftKey); + break; + case 35: // End + if(e.ctrlKey) + replMain.caretEndAll(!e.shiftKey) + else + replMain.caretEnd(!e.shiftKey); + break; + case 38: // Up + replMain.caretUp(!e.shiftKey); + break; + case 40: // Down + replMain.caretDown(!e.shiftKey); + break; + case 46: // Delete + replMain.delete(); + break; + } + } +}) + +let replMain = new ReplConsole(document.getElementById("repl") as HTMLDivElement); + +document.addEventListener("cut", e => { + e.clipboardData.setData("text/plain", replMain.model.getText(replMain.cursorStart, replMain.cursorEnd)); + replMain.delete(); + e.preventDefault(); +}) + +document.addEventListener("copy", e => { + e.clipboardData.setData("text/plain", replMain.model.getText(replMain.cursorStart, replMain.cursorEnd)); + e.preventDefault(); +}) + +document.addEventListener("paste", e => { + replMain.insertString(e.clipboardData.getData("text/plain")); + console.log("Pasted "+e.clipboardData.getData("text/plain")); + e.preventDefault(); +}) diff --git a/src/server/main.ts b/src/server/main.ts new file mode 100644 index 0000000..eaa015a --- /dev/null +++ b/src/server/main.ts @@ -0,0 +1,25 @@ +import * as express from 'express'; +import * as compression from 'compression'; + +let app = express(); +function shouldCompress (req, res) { + if (req.headers['x-no-compression']) { + // don't compress responses with this request header + return false + } + + if(req.path.match(".bin$")) + return true; + + // fallback to standard filter function + return compression.filter(req, res) + } + +app.use(compression({ filter: shouldCompress })) +app.get("/api/foo", (req, res) => { + res.send("Fucking woot") +}) +app.use(express.static(__dirname+"/../../www")); + + +app.listen(3001); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c565d33 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "target": "es6", + "jsx": "react", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "declaration": false, + "noImplicitAny": false, + "noImplicitUseStrict": false, + "removeComments": true, + "noLib": false, + "preserveConstEnums": true, + "suppressImplicitAnyIndexErrors": true + }, + "compileOnSave": false, + "buildOnSave": false, +} diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..eaeccf1 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,44 @@ +const path = require('path'); +module.exports = { + entry: './src/client/main.ts', + mode: "development", + externals: {}, + module: { + rules: [ + { + test: /\.tsx?$/, + loader: 'ts-loader', + options: { + transpileOnly: true, + experimentalWatchApi: true, + }, + exclude: /node_modules/ + }, + { + test: /\.scss$/, + use: ['style-loader', 'css-loader', 'sass-loader'] + } + ] + }, + watchOptions: { + aggregateTimeout: 200, + poll: 500, + ignored: /node_modules/ + }, + resolve: { + extensions: [ '.tsx', '.ts', '.js' ] + }, + output: { + filename: 'main.js', + path: path.resolve(__dirname, 'dist') + }, + devServer: { + historyApiFallback: true, + host: "0.0.0.0", + compress: true, + contentBase: path.join(__dirname, 'www'), + proxy: { + '/api': 'http://localhost:3000', + } + } +}; \ No newline at end of file diff --git a/www/clojure-logo.svg b/www/clojure-logo.svg new file mode 100644 index 0000000..152078c --- /dev/null +++ b/www/clojure-logo.svg @@ -0,0 +1,50 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/www/index.html b/www/index.html new file mode 100644 index 0000000..e502984 --- /dev/null +++ b/www/index.html @@ -0,0 +1,87 @@ + + + + + +
+
+ + + \ No newline at end of file