diff --git a/.gitignore b/.gitignore index ac1e8f7..8039e3e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules *.log .DS_Store +.vscode diff --git a/README.md b/README.md index 1893bb6..ce8b9f9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,15 @@ -# LiveCoding +# **LiveCoding** +Coding client-server -Live Coding Server +## for teacher: +pass command-line arguments as your password before starting live-coding lesson + +`node ./server.js qwerty123` or `npm start qwerty123` + + +**to login as teacher,** +go to http://127.0.0.1:8000/teacher + + +## for student: +enter username in field then press `Enter` to register in system diff --git a/authorize.html b/authorize.html new file mode 100644 index 0000000..5ddb09e --- /dev/null +++ b/authorize.html @@ -0,0 +1,47 @@ + + + + + + + Welcome! + + + +
+ + +
+ +
+ +
+ + + + + + diff --git a/client.js b/client.js index e458139..39cfda8 100644 --- a/client.js +++ b/client.js @@ -5,83 +5,140 @@ const socket = new WebSocket('ws://127.0.0.1:8000/'); const user = document.getElementById('user'); const source = document.getElementById('source'); const buttons = document.getElementById('buttons'); +const regButton = document.getElementById('register'); const settings = document.getElementById('settings'); +const commit = document.getElementById('commit'); + +// editor +const logs = document.getElementById('log'); +const snippets = document.getElementById('snippets'); + + +logs.style.fontSize = '16px'; +snippets.onchange = () => { + editor.setOptions({ + enableSnippets: snippets.checked, + enableBasicAutocompletion: snippets.checked + }); +}; + const clients = {}; let selectedClient = null; let selectedButton = null; let localUser = ''; +const ranges = []; +const markers = []; + + +const addSymbol = () => { + const pos = editor.getCursorPosition(); + const marker = markers.find(marker => + marker.contains(pos.row, pos.column - 1)); + + if (marker) { + marker.setEnd(marker.end.row, marker.end.column + 1); + editor.session.removeMarker(marker.id); + marker.id = editor.session.addMarker(marker, 'commit', 'text'); + } else { + const m = new Range.Range(pos.row, pos.column - 1, pos.row, pos.column); + m.id = editor.session.addMarker(m, 'commit', 'text'); + markers.push(m); + } +}; + +const removeSymbol = () => { + const pos = editor.getCursorPosition(); + const marker = markers.find(marker => + marker.contains(pos.row, pos.column - 1)); + + if (marker) { + marker.setEnd(marker.end.row, marker.end.column - 1); + editor.session.removeMarker(marker.id); + marker.id = editor.session.addMarker(marker, 'commit', 'text'); + } +}; const toElement = (html) => new DOMParser() .parseFromString(html, 'text/html') .body.childNodes[0]; -const showSource = (clientName) => { - selectedClient = clientName; - const client = clients[clientName]; - if (client) { - source.value = client.source; - } +const register = () => { + localUser = user.value; + const event = { + client: { + name: user.value + } + }; + settings.hidden = true; + socket.send(JSON.stringify(event)); + regButton.style.backgroundColor = 'limegreen'; + regButton.textContent = 'Registered'; }; -const addClient = (client) => { - const button = toElement( - '' - ); - buttons.appendChild(button); - button.clientName = client.name; - button.addEventListener('click', () => { - if (button.clientName !== selectedButton) { - if (selectedButton) selectedButton.className = ''; - button.className = 'selected'; - selectedButton = button; - selectedClient = button.clientName; - showSource(selectedClient); +const myLog = (args) => { + const parse = (obj) => { + if (obj === null) return null; + if (typeof obj === 'function') return `[Function ${obj.name}]`; + if (typeof obj === 'object') { + if (Array.isArray(obj)) { + return `[ ${obj.reduce((res, o) => ( + res + parse(o) + ', ' + ), '').replace(/, $/, '')} ]`; + } else { + return `{ ${Object.keys(obj).reduce((res, k) => ( + res + `${k}: ${parse(obj[k])}, `), '' + ).replace(/, $/, '')} }`; + } + } else { + return typeof obj === 'string' ? `'${obj}'` : obj; } - }); - clients[client.name] = { button, client, source: '' }; - return button; + }; + return args.reduce((res, o) => (res + parse(o) + ' '), ''); }; -const changeSource = (edit) => { - const client = clients[edit.name]; - client.source = edit.value; - if (selectedClient === edit.name) { - showSource(edit.name); +const run = () => { + logs.style.backgroundColor = 'dodgerblue'; + logs.textContent = 'Logs:\n\n'; + const annot = editor.getSession().getAnnotations(); + if (annot.length > 0 && annot.every(a => a.type === 'error')) { + annot.map(a => { + logs.textContent += `${a.row}:${a.column}\t${a.text}\n`; + }); + return; + } + try { + (() => { + const outLog = []; + console.log = (...args) => { + outLog.push(myLog(args)); + }; + console.dir = (...args) => { + outLog.push(myLog(args)); + }; + eval(editor.getValue()); + logs.textContent = 'Logs:\n\n' + outLog.reduce((res, v) => ( + res + v + '\n' + ), ''); + })(); + } catch (e) { + logs.style.backgroundColor = 'tomato'; + logs.textContent = 'Errors:\n\n' + e.message; } }; user.addEventListener('keydown', (event) => { if (event.keyCode === 13) { - localUser = user.value; - const event = { - client: { - name: user.value - } - }; - settings.hidden = true; - socket.send(JSON.stringify(event)); - const button = addClient(event.client); - button.className = 'selected'; + register(); } }); source.addEventListener('input', (event) => { - const client = clients[localUser]; - client.source = source.value; socket.send(JSON.stringify({ edit: { name: localUser, - value: source.value + value: editor.getValue() } })); }); -socket.onmessage = (event) => { - const change = JSON.parse(event.data); - console.log(event.data); - if (change.client) { - addClient(change.client); - } else if (change.edit) { - changeSource(change.edit); - } -}; +user.focus(); diff --git a/editor.js b/editor.js new file mode 100644 index 0000000..fac731e --- /dev/null +++ b/editor.js @@ -0,0 +1,50 @@ +'use strict'; + +const fontSize = document.getElementById('fontSize'); +const selectTheme = document.getElementById('selectTheme'); + +ace.require('ace/ext/language_tools'); +const Range = ace.require('ace/range'); + +const editor = ace.edit('source'); + +editor.session.setMode('ace/mode/javascript'); + +editor.setOptions({ + fontSize: 16, + tabSize: 2, + useSoftTabs: false, + theme: 'ace/theme/cobalt', + enableSnippets: false, + enableBasicAutocompletion: false +}); + +// window.onwheel = (e) => { +// table.style.height = table.clientHeight + e.deltaY + 'px'; +// }; + +selectTheme.onchange = (event) => { + editor.setTheme(selectTheme.value); + if (selectTheme.selectedIndex > 15) { + document.body.style.backgroundColor = '#222'; + document.body.style.color = '#FFF'; + } else { + document.body.style.backgroundColor = '#CCC'; + document.body.style.color = '#000'; + } +}; + +fontSize.oninput = () => { + editor.setFontSize(parseInt(fontSize.value)); + logs.style.fontSize = `${fontSize.value}px`; +}; + +const reset = () => { + Array.prototype.forEach.call(document.body.children, el => { + if (el.nodeName === 'section') { + el.style['width'] = 'initial'; + el.style['height'] = 'initial'; + } + }); + +}; diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..5e49518 Binary files /dev/null and b/favicon.ico differ diff --git a/index.html b/index.html index 672192f..22b0341 100644 --- a/index.html +++ b/index.html @@ -1,14 +1,102 @@ + - + Live coding + + + + -
Name:
-
- - + --> + + + + + diff --git a/package-lock.json b/package-lock.json index f72c7d9..1637d18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,9 +1,14 @@ { - "name": "metacode", - "version": "0.1.0", + "name": "livecoding", + "version": "0.1.1", "lockfileVersion": 1, "requires": true, "dependencies": { + "ace-builds": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.3.3.tgz", + "integrity": "sha512-PbSdoHw42kt5vaXkEVSfUYCd3K1BCfAvyXW9TvR/2ATkk65oImjS1v0evHmzHhOYPSTUO8BprvmpfYT9Vp2akA==" + }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", diff --git a/package.json b/package.json index 88d1ea7..1f1a600 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "livecoding", - "version": "0.1.0", + "version": "0.1.1", "author": "Timur Shemsedinov ", "description": "Live Coding Environment for How.Programming.Works", "license": "MIT", "dependencies": { + "ace-builds": "^1.3.3", "websocket": "1.0.x" }, "readmeFilename": "README.md" diff --git a/server.js b/server.js index 73ca8b2..b14f31e 100644 --- a/server.js +++ b/server.js @@ -4,16 +4,38 @@ const fs = require('fs'); const http = require('http'); const Websocket = require('websocket').server; -const files = {}; -['index.html', 'client.js', 'styles.css'].forEach((fileName, i) => { - const key = '/' + (i === 0 ? '' : fileName); - files[key] = fs.readFileSync('./' + fileName); -}); +if (process.argv.length < 3) { + console.log('Please, specify a teacher\'s password in cmd arguments.'); + process.exit(0); +} +const password = process.argv[2]; +console.log('Your password: ' + password); + +const router = { + '/$': () => './index.html', + '/ace': (url) => './node_modules/ace-builds' + url.slice(4), + '/teacher$': () => './authorize.html' +}; + +router['/' + password + '$'] = () => './teacher-index.html'; + +const getRoutePath = (req) => { + for (const k in router) { + if (new RegExp(k).test(req.url)) { + return router[k](req.url); + } + } + return './' + req.url; +}; const server = http.createServer((req, res) => { - const data = files[req.url] || files['/']; res.writeHead(200); - res.end(data); + + res.writeHead(200); + fs.readFile(getRoutePath(req), (err, data) => { + if (err) { console.log(err.message); } + res.end(data); + }); }); server.listen(8000, () => { @@ -25,24 +47,49 @@ const ws = new Websocket({ autoAcceptConnections: false }); +let teacher = null; const clients = []; ws.on('request', (req) => { const connection = req.accept('', req.origin); - clients.push(connection); + if (req.resourceURL.href === '/t') { + teacher = connection; + teacher.send(JSON.stringify({ + updateClients: clients.map(client => ({ name: client.username })) + })); + console.log('teacher connected'); + } else { + clients.push(connection); + } console.log('Connected ' + connection.remoteAddress); + connection.on('message', (message) => { const dataName = message.type + 'Data'; const data = message[dataName]; console.log('Received: ' + data); - clients.forEach((client) => { - if (connection !== client) { - client.send(data); - } - }); + const parsed = JSON.parse(data); + if (parsed.client) { + connection.username = parsed.client.name; + } + if (teacher) { + teacher.send(data); + } }); + connection.on('close', (reasonCode, description) => { console.log('Disconnected ' + connection.remoteAddress); - console.dir({ reasonCode, description }); + const clIndex = clients.findIndex((c) => c === connection); + if (clIndex > -1) { + clients.splice(clIndex, 1); + } + if (teacher) { + teacher.send(JSON.stringify({ + removeClient: { name: connection.username } + })); + } + console.dir({ + reasonCode, + description + }); }); }); diff --git a/style.css b/style.css new file mode 100644 index 0000000..599e570 --- /dev/null +++ b/style.css @@ -0,0 +1,52 @@ +html, body { + position: relative; + height: 100%; + -webkit-user-select: none; + user-select: none; + font-family: 'Consolas', 'Courier New', Courier, monospace; + background-color: #222; + margin: 0px; + color: white; +} + +table { + width: 100%; + height: 88%; + /* left: 0px; + right: 0px; */ +} + +#resize { + overflow: auto; + resize: both +} + +.commit { + position: absolute; + background-color: lime; + opacity: 0.5; + z-index: 1000; +} + +.selected { + background-color: forestgreen !important; +} + +#source { + width: calc(100% - 30px); + height: calc(100% - 50px); + min-height: 300px; +} + +#log { + white-space: pre-wrap; + width: calc(100% - 30px); + height: calc(100% - 50px); + opacity: 0.8; + min-height: 300px; +} + +.numericUpDown { + padding: 0px; + height: 30px !important; +} \ No newline at end of file diff --git a/style28.css b/style28.css new file mode 100644 index 0000000..16b379c --- /dev/null +++ b/style28.css @@ -0,0 +1,237 @@ +a { + text-decoration-style: none; + color: white; + display: block; + padding: 10px; +} + +.circle { + border-radius: 2px; + box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.3); + font-family: 'Consolas', 'Courier New', Courier, monospace; + text-align: center; + vertical-align: middle; + min-width: 100px; + width: auto; + height: 50px; + outline: none; + border: none; + padding-left: 10px; + padding-right: 10px; + margin: 10px; + transition-duration: 0.5s; +} + +.circle:hover { + transition-duration: 0.2s; + box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.3); +} + +.buttonCanv { + border-radius: inherit; + position: absolute; + top: 0px; + left: 0px; + width: 100%; + height: 100%; +} + +.buttonCanv:hover { + background-color: rgba(0, 0, 0, 0.05); + transition-duration: 0.5s; +} + +.buttonCanv:active { + background-color: rgba(0, 0, 0, 0.1); + transition-duration: 0.5s; +} + +.circleButton { + background-color: orange; + position: relative; +} + +/* #region dropdown*/ + +.circleDropdown { + z-index: 1; + border: 0px; + outline: none; + background-color: orange; + border-radius: 5px; + position: relative; + width: 300px; + height: 20px; + text-align: center; + font-size: 100%; + font-family: 'Consolas', 'Courier New', Courier, monospace; + box-shadow: 0px 0px 30px 2px grey; + transition-duration: 1s; + margin: 10px; + list-style-type: none; + cursor: default; + /* padding: 10px; */ + padding-top: 10px; + padding-bottom: 10px; +} + +.circleDropdown:hover .dropdownul { + box-shadow: 0px 10px 30px 5px grey; +} + +.circleDropdown:hover .dropdownul li { + visibility: visible; + opacity: 1; + max-height: 500px; + /* padding: 10px; */ + transition: 0.5s; +} + +.dropdownul { + width: 100%; + list-style-type: none; + padding-left: 0px; + margin-top: 11px; +} + +.dropdownul li { + visibility: hidden; + opacity: 0; + max-height: 0px; + transition-duration: 0.5s; + cursor: pointer; + background-color: violet; +} + +.dropdownul li:hover { + background-color: steelblue; + transition-duration: 0.5s; +} + +/* #endregion */ + +/* #region dropdownClick*/ + +/* #region textfield*/ + +.inputText { + width: auto; + /* padding: 0px; */ + background-color: darkgrey; + border: 2px solid; + border-color: mediumslateblue; + text-align: left; + font-size: x-large; +} + +.inputText:focus { + box-shadow: 0px 4px 16px 4px rgba(0, 0, 0, 0.2); + border-color: transparent; + transition-duration: 0.2s; +} + +/* #endregion */ + +.textArea { + background-color: greenyellow; + text-align: initial; + font-size: large; + transition-duration: 0s; + resize: both; + overflow: auto; +} + +.inputCheckbox { + border-radius: 2px; + display: inline-block; + font-family: 'Consolas', 'Courier New', Courier, monospace; + outline: none; + border: none; + padding: 10px; + margin: 10px; + position: relative; + padding-left: 30px; + transition-duration: 0.5s; + cursor: pointer; + user-select: none; + -moz-user-select: none; + -ms-user-select: none; + -webkit-user-select: none; +} + +.inputCheckbox input { + display: none; +} + +@keyframes check { + 0% { + border-radius: 2px; + background-color: transparent; + } + 50% { + border-radius: 50%; + background-color: orange; + } + 100% { + border-radius: 2px; + background-color: orange; + } +} + +@keyframes uncheck { + 0% {} + 50% { + border-radius: 50%; + background-color: orange; + } + 100% { + border-radius: 2px; + background-color: transparent; + } +} + +.checkMark { + height: 20px; + width: 20px; + left: 0px; + top: 5px; + position: absolute; + border: 3px orange solid; + animation: uncheck 0.3s forwards; +} + +.inputCheckbox input:checked~.checkMark { + animation: check 0.3s forwards; +} + +.checkMark div { + opacity: 0; + width: 30%; + height: 60%; + transform: translate(6px, 0px); + border-width: 0px 3px 3px 0px; + border-color: white; + border-style: solid; + background-color: transparent; + transition-duration: 0.25s; +} + +.inputCheckbox input:checked~.checkMark div { + opacity: 1; + transform: translate(6px, 0px) rotate(45deg); + transition-duration: 0.2s; + transition-delay: 0.2s; +} + +::-webkit-scrollbar-thumb { + background-color: silver; + border-radius: 10px; +} + +::-webkit-scrollbar-track-piece { + background-color: palegoldenrod; +} + +::-webkit-scrollbar { + background-color: transparent; +} \ No newline at end of file diff --git a/style28.js b/style28.js new file mode 100644 index 0000000..27c9220 --- /dev/null +++ b/style28.js @@ -0,0 +1,33 @@ +'use strict'; +const circleCanvases = document.getElementsByClassName('buttonCanv'); + +Array.prototype.forEach.call(circleCanvases, canvas => { + let offset = {}; + + canvas.width = canvas.offsetWidth; + canvas.height = canvas.offsetHeight; + + const ctx = canvas.getContext('2d'); + const delta = Math.floor(Math.sqrt(canvas.width * canvas.height) / 10); + const max = Math.floor(Math.sqrt(Math.pow(canvas.width, 2) + + Math.pow(canvas.height, 2))); + + let t = null; + canvas.onmousedown = e => { + offset = canvas.getBoundingClientRect(); + let size = 0; + clearInterval(t); + t = setInterval(() => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = `rgba(0, 0, 0, ${5 * (1 / size)})`; + ctx.beginPath(); + ctx.arc(e.clientX - offset.left, e.clientY - offset.top, size, 0, 2 * Math.PI); + ctx.fill(); + size += delta; + if (size > max) { + clearInterval(t); + ctx.clearRect(0, 0, canvas.width, canvas.height); + } + }, 20); + }; +}); diff --git a/teacher-client.js b/teacher-client.js new file mode 100644 index 0000000..a09a80d --- /dev/null +++ b/teacher-client.js @@ -0,0 +1,80 @@ +'use strict'; + +const socket = new WebSocket('ws://127.0.0.1:8000/t'); +const source = document.getElementById('source'); +const buttons = document.getElementById('buttons'); + +const clients = {}; +let selectedClient = null; +let selectedButton = null; + +const toElement = (html) => new DOMParser() + .parseFromString(html, 'text/html') + .body.childNodes[0]; + +const showSource = (clientName) => { + selectedClient = clientName; + const client = clients[clientName]; + if (client) { + editor.setValue(client.source, 1); + } + // selectedButton.classList.remove('selected'); + const button = document.getElementById('button' + clientName); + selectedButton = button; + button.classList.add('selected'); +}; + +const addClient = (client) => { + const button = toElement( + '' + ); + // button.classList.add('circleButton', 'circle'); + buttons.appendChild(button); + button.clientName = client.name; + button.addEventListener('click', () => { + if (button.clientName !== selectedButton) { + if (selectedButton) selectedButton.classList.remove('selected'); + button.classList.add('selected'); + selectedButton = button; + selectedClient = button.clientName; + showSource(selectedClient); + } + }); + clients[client.name] = { button, client, source: '' }; + return button; +}; + +const removeClient = (client) => { + buttons.removeChild(document.getElementById('button' + client.name)); + showSource(buttons.firstElementChild.clientName); +}; + +const updateClients = (clients) => { + clients.forEach(client => { + addClient(client); + }); +}; + +const changeSource = (edit) => { + const client = clients[edit.name]; + client.source = edit.value; + if (selectedClient === edit.name) { + showSource(edit.name); + } +}; + + +socket.onmessage = (event) => { + const change = JSON.parse(event.data); + if (change.client) { + addClient(change.client); + } else if (change.edit) { + changeSource(change.edit); + } else if (change.removeClient) { + removeClient(change.removeClient); + } else if (change.updateClients) { + updateClients(change.updateClients); + } +}; + +editor.setReadOnly(true); diff --git a/teacher-index.html b/teacher-index.html new file mode 100644 index 0000000..011a207 --- /dev/null +++ b/teacher-index.html @@ -0,0 +1,67 @@ + + + + Live Coding + + + + + + +
+
+ + +
+ + + + + + + + \ No newline at end of file