commit
d59c0e876a
@ -0,0 +1,16 @@ |
|||||||
|
require('./lib/polyfills') |
||||||
|
require('./modal') |
||||||
|
require('./notif') |
||||||
|
require('./appcommon') |
||||||
|
try { require('./term/demo') } catch (err) {} |
||||||
|
require('./wifi') |
||||||
|
|
||||||
|
const $ = require('./lib/chibi') |
||||||
|
const { qs } = require('./utils') |
||||||
|
|
||||||
|
/* Export stuff to the global scope for inline scripts */ |
||||||
|
window.termInit = require('./term') |
||||||
|
window.$ = $ |
||||||
|
window.qs = qs |
||||||
|
|
||||||
|
window.themes = require('./term/themes') |
@ -1,311 +0,0 @@ |
|||||||
// keymaster.js
|
|
||||||
// (c) 2011-2013 Thomas Fuchs
|
|
||||||
// keymaster.js may be freely distributed under the MIT license.
|
|
||||||
|
|
||||||
;(function(global){ |
|
||||||
var k, |
|
||||||
_handlers = {}, |
|
||||||
_mods = { 16: false, 18: false, 17: false, 91: false }, |
|
||||||
_scope = 'all', |
|
||||||
// modifier keys
|
|
||||||
_MODIFIERS = { |
|
||||||
'⇧': 16, shift: 16, |
|
||||||
'⌥': 18, alt: 18, option: 18, |
|
||||||
'⌃': 17, ctrl: 17, control: 17, |
|
||||||
'⌘': 91, command: 91 |
|
||||||
}, |
|
||||||
// special keys
|
|
||||||
_MAP = { |
|
||||||
backspace: 8, tab: 9, clear: 12, |
|
||||||
enter: 13, 'return': 13, |
|
||||||
esc: 27, escape: 27, space: 32, |
|
||||||
left: 37, up: 38, |
|
||||||
right: 39, down: 40, |
|
||||||
del: 46, 'delete': 46, |
|
||||||
home: 36, end: 35, |
|
||||||
pageup: 33, pagedown: 34, |
|
||||||
',': 188, '.': 190, '/': 191, |
|
||||||
'`': 192, '-': 189, '=': 187, |
|
||||||
';': 186, '\'': 222, |
|
||||||
'[': 219, ']': 221, '\\': 220, |
|
||||||
// added:
|
|
||||||
insert: 45, |
|
||||||
np_0: 96, np_1: 97, np_2: 98, np_3: 99, np_4: 100, np_5: 101, |
|
||||||
np_6: 102, np_7: 103, np_8: 104, np_9: 105, np_mul: 106, |
|
||||||
np_add: 107, np_sub: 109, np_point: 110, np_div: 111, numlock: 144, |
|
||||||
}, |
|
||||||
code = function(x){ |
|
||||||
return _MAP[x] || x.toUpperCase().charCodeAt(0); |
|
||||||
}, |
|
||||||
_downKeys = []; |
|
||||||
|
|
||||||
for(k=1;k<20;k++) _MAP['f'+k] = 111+k; |
|
||||||
|
|
||||||
// IE doesn't support Array#indexOf, so have a simple replacement
|
|
||||||
function index(array, item){ |
|
||||||
var i = array.length; |
|
||||||
while(i--) if(array[i]===item) return i; |
|
||||||
return -1; |
|
||||||
} |
|
||||||
|
|
||||||
// for comparing mods before unassignment
|
|
||||||
function compareArray(a1, a2) { |
|
||||||
if (a1.length != a2.length) return false; |
|
||||||
for (var i = 0; i < a1.length; i++) { |
|
||||||
if (a1[i] !== a2[i]) return false; |
|
||||||
} |
|
||||||
return true; |
|
||||||
} |
|
||||||
|
|
||||||
var modifierMap = { |
|
||||||
16:'shiftKey', |
|
||||||
18:'altKey', |
|
||||||
17:'ctrlKey', |
|
||||||
91:'metaKey' |
|
||||||
}; |
|
||||||
function updateModifierKey(event) { |
|
||||||
for(k in _mods) _mods[k] = event[modifierMap[k]]; |
|
||||||
}; |
|
||||||
|
|
||||||
function isModifierPressed(mod) { |
|
||||||
if (mod=='control'||mod=='ctrl') return _mods[17]; |
|
||||||
if (mod=='shift') return _mods[16]; |
|
||||||
if (mod=='meta') return _mods[91]; |
|
||||||
if (mod=='alt') return _mods[18]; |
|
||||||
return false; |
|
||||||
} |
|
||||||
|
|
||||||
// handle keydown event
|
|
||||||
function dispatch(event) { |
|
||||||
var key, handler, k, i, modifiersMatch, scope; |
|
||||||
key = event.keyCode; |
|
||||||
|
|
||||||
if (index(_downKeys, key) == -1) { |
|
||||||
_downKeys.push(key); |
|
||||||
} |
|
||||||
|
|
||||||
// if a modifier key, set the key.<modifierkeyname> property to true and return
|
|
||||||
if(key == 93 || key == 224) key = 91; // right command on webkit, command on Gecko
|
|
||||||
if(key in _mods) { |
|
||||||
_mods[key] = true; |
|
||||||
// 'assignKey' from inside this closure is exported to window.key
|
|
||||||
for(k in _MODIFIERS) if(_MODIFIERS[k] == key) assignKey[k] = true; |
|
||||||
return; |
|
||||||
} |
|
||||||
updateModifierKey(event); |
|
||||||
|
|
||||||
// see if we need to ignore the keypress (filter() can can be overridden)
|
|
||||||
// by default ignore key presses if a select, textarea, or input is focused
|
|
||||||
if(!assignKey.filter.call(this, event)) return; |
|
||||||
|
|
||||||
// abort if no potentially matching shortcuts found
|
|
||||||
if (!(key in _handlers)) return; |
|
||||||
|
|
||||||
scope = getScope(); |
|
||||||
|
|
||||||
// for each potential shortcut
|
|
||||||
for (i = 0; i < _handlers[key].length; i++) { |
|
||||||
handler = _handlers[key][i]; |
|
||||||
|
|
||||||
// see if it's in the current scope
|
|
||||||
if(handler.scope == scope || handler.scope == 'all'){ |
|
||||||
// check if modifiers match if any
|
|
||||||
modifiersMatch = handler.mods.length > 0; |
|
||||||
for(k in _mods) |
|
||||||
if((!_mods[k] && index(handler.mods, +k) > -1) || |
|
||||||
(_mods[k] && index(handler.mods, +k) == -1)) modifiersMatch = false; |
|
||||||
// call the handler and stop the event if neccessary
|
|
||||||
if((handler.mods.length == 0 && !_mods[16] && !_mods[18] && !_mods[17] && !_mods[91]) || modifiersMatch){ |
|
||||||
if(handler.method(event, handler)===false){ |
|
||||||
if(event.preventDefault) event.preventDefault(); |
|
||||||
else event.returnValue = false; |
|
||||||
if(event.stopPropagation) event.stopPropagation(); |
|
||||||
if(event.cancelBubble) event.cancelBubble = true; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
// unset modifier keys on keyup
|
|
||||||
function clearModifier(event){ |
|
||||||
var key = event.keyCode, k, |
|
||||||
i = index(_downKeys, key); |
|
||||||
|
|
||||||
// remove key from _downKeys
|
|
||||||
if (i >= 0) { |
|
||||||
_downKeys.splice(i, 1); |
|
||||||
} |
|
||||||
|
|
||||||
if(key == 93 || key == 224) key = 91; |
|
||||||
if(key in _mods) { |
|
||||||
_mods[key] = false; |
|
||||||
for(k in _MODIFIERS) if(_MODIFIERS[k] == key) assignKey[k] = false; |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
function resetModifiers() { |
|
||||||
for(k in _mods) _mods[k] = false; |
|
||||||
for(k in _MODIFIERS) assignKey[k] = false; |
|
||||||
}; |
|
||||||
|
|
||||||
// parse and assign shortcut
|
|
||||||
function assignKey(key, scope, method){ |
|
||||||
var keys, mods; |
|
||||||
keys = getKeys(key); |
|
||||||
if (method === undefined) { |
|
||||||
method = scope; |
|
||||||
scope = 'all'; |
|
||||||
} |
|
||||||
|
|
||||||
// for each shortcut
|
|
||||||
for (var i = 0; i < keys.length; i++) { |
|
||||||
// set modifier keys if any
|
|
||||||
mods = []; |
|
||||||
key = keys[i].split('+'); |
|
||||||
if (key.length > 1){ |
|
||||||
mods = getMods(key); |
|
||||||
key = [key[key.length-1]]; |
|
||||||
} |
|
||||||
// convert to keycode and...
|
|
||||||
key = key[0] |
|
||||||
key = code(key); |
|
||||||
// ...store handler
|
|
||||||
if (!(key in _handlers)) _handlers[key] = []; |
|
||||||
_handlers[key].push({ shortcut: keys[i], scope: scope, method: method, key: keys[i], mods: mods }); |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
// unbind all handlers for given key in current scope
|
|
||||||
function unbindKey(key, scope) { |
|
||||||
var multipleKeys, keys, |
|
||||||
mods = [], |
|
||||||
i, j, obj; |
|
||||||
|
|
||||||
multipleKeys = getKeys(key); |
|
||||||
|
|
||||||
for (j = 0; j < multipleKeys.length; j++) { |
|
||||||
keys = multipleKeys[j].split('+'); |
|
||||||
|
|
||||||
if (keys.length > 1) { |
|
||||||
mods = getMods(keys); |
|
||||||
} |
|
||||||
|
|
||||||
key = keys[keys.length - 1]; |
|
||||||
key = code(key); |
|
||||||
|
|
||||||
if (scope === undefined) { |
|
||||||
scope = getScope(); |
|
||||||
} |
|
||||||
if (!_handlers[key]) { |
|
||||||
return; |
|
||||||
} |
|
||||||
for (i = 0; i < _handlers[key].length; i++) { |
|
||||||
obj = _handlers[key][i]; |
|
||||||
// only clear handlers if correct scope and mods match
|
|
||||||
if (obj.scope === scope && compareArray(obj.mods, mods)) { |
|
||||||
_handlers[key][i] = {}; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
// Returns true if the key with code 'keyCode' is currently down
|
|
||||||
// Converts strings into key codes.
|
|
||||||
function isPressed(keyCode) { |
|
||||||
if (typeof(keyCode)=='string') { |
|
||||||
keyCode = code(keyCode); |
|
||||||
} |
|
||||||
return index(_downKeys, keyCode) != -1; |
|
||||||
} |
|
||||||
|
|
||||||
function getPressedKeyCodes() { |
|
||||||
return _downKeys.slice(0); |
|
||||||
} |
|
||||||
|
|
||||||
function filter(event){ |
|
||||||
var tagName = (event.target || event.srcElement).tagName; |
|
||||||
// ignore keypressed in any elements that support keyboard data input
|
|
||||||
return !(tagName == 'INPUT' || tagName == 'SELECT' || tagName == 'TEXTAREA'); |
|
||||||
} |
|
||||||
|
|
||||||
// initialize key.<modifier> to false
|
|
||||||
for(k in _MODIFIERS) assignKey[k] = false; |
|
||||||
|
|
||||||
// set current scope (default 'all')
|
|
||||||
function setScope(scope){ _scope = scope || 'all' }; |
|
||||||
function getScope(){ return _scope || 'all' }; |
|
||||||
|
|
||||||
// delete all handlers for a given scope
|
|
||||||
function deleteScope(scope){ |
|
||||||
var key, handlers, i; |
|
||||||
|
|
||||||
for (key in _handlers) { |
|
||||||
handlers = _handlers[key]; |
|
||||||
for (i = 0; i < handlers.length; ) { |
|
||||||
if (handlers[i].scope === scope) handlers.splice(i, 1); |
|
||||||
else i++; |
|
||||||
} |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
// abstract key logic for assign and unassign
|
|
||||||
function getKeys(key) { |
|
||||||
var keys; |
|
||||||
key = key.replace(/\s/g, ''); |
|
||||||
keys = key.split(','); |
|
||||||
if ((keys[keys.length - 1]) == '') { |
|
||||||
keys[keys.length - 2] += ','; |
|
||||||
} |
|
||||||
return keys; |
|
||||||
} |
|
||||||
|
|
||||||
// abstract mods logic for assign and unassign
|
|
||||||
function getMods(key) { |
|
||||||
var mods = key.slice(0, key.length - 1); |
|
||||||
for (var mi = 0; mi < mods.length; mi++) |
|
||||||
mods[mi] = _MODIFIERS[mods[mi]]; |
|
||||||
return mods; |
|
||||||
} |
|
||||||
|
|
||||||
// cross-browser events
|
|
||||||
function addEvent(object, event, method) { |
|
||||||
if (object.addEventListener) |
|
||||||
object.addEventListener(event, method, false); |
|
||||||
else if(object.attachEvent) |
|
||||||
object.attachEvent('on'+event, function(){ method(window.event) }); |
|
||||||
}; |
|
||||||
|
|
||||||
// set the handlers globally on document
|
|
||||||
addEvent(document, 'keydown', function(event) { dispatch(event) }); // Passing _scope to a callback to ensure it remains the same by execution. Fixes #48
|
|
||||||
addEvent(document, 'keyup', clearModifier); |
|
||||||
|
|
||||||
// reset modifiers to false whenever the window is (re)focused.
|
|
||||||
addEvent(window, 'focus', resetModifiers); |
|
||||||
|
|
||||||
// store previously defined key
|
|
||||||
var previousKey = global.key; |
|
||||||
|
|
||||||
// restore previously defined key and return reference to our key object
|
|
||||||
function noConflict() { |
|
||||||
var k = global.key; |
|
||||||
global.key = previousKey; |
|
||||||
return k; |
|
||||||
} |
|
||||||
|
|
||||||
// set window.key and window.key.set/get/deleteScope, and the default filter
|
|
||||||
global.key = assignKey; |
|
||||||
global.key.setScope = setScope; |
|
||||||
global.key.getScope = getScope; |
|
||||||
global.key.deleteScope = deleteScope; |
|
||||||
global.key.filter = filter; |
|
||||||
global.key.isPressed = isPressed; |
|
||||||
global.key.isModifier = isModifierPressed; |
|
||||||
global.key.getPressedKeyCodes = getPressedKeyCodes; |
|
||||||
global.key.noConflict = noConflict; |
|
||||||
global.key.unbind = unbindKey; |
|
||||||
|
|
||||||
if(typeof module !== 'undefined') module.exports = assignKey; |
|
||||||
|
|
||||||
})(window); |
|
||||||
|
|
@ -0,0 +1,190 @@ |
|||||||
|
const EventEmitter = require('events') |
||||||
|
const $ = require('../lib/chibi') |
||||||
|
let demo |
||||||
|
try { demo = require('./demo') } catch (err) {} |
||||||
|
|
||||||
|
/** Handle connections */ |
||||||
|
module.exports = class TermConnection extends EventEmitter { |
||||||
|
constructor (screen) { |
||||||
|
super() |
||||||
|
|
||||||
|
this.screen = screen |
||||||
|
this.ws = null |
||||||
|
this.heartbeatTimeout = null |
||||||
|
this.pingInterval = null |
||||||
|
this.xoff = false |
||||||
|
this.autoXoffTimeout = null |
||||||
|
this.reconnTimeout = null |
||||||
|
this.forceClosing = false |
||||||
|
|
||||||
|
this.pageShown = false |
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => { |
||||||
|
if (document.hidden === true) { |
||||||
|
console.info('Window lost focus, freeing socket') |
||||||
|
this.closeSocket() |
||||||
|
clearTimeout(this.heartbeatTimeout) |
||||||
|
} else { |
||||||
|
console.info('Window got focus, re-connecting') |
||||||
|
this.init() |
||||||
|
} |
||||||
|
}, false) |
||||||
|
} |
||||||
|
|
||||||
|
onWSOpen (evt) { |
||||||
|
console.log('CONNECTED') |
||||||
|
this.heartbeat() |
||||||
|
this.send('i') |
||||||
|
this.forceClosing = false |
||||||
|
|
||||||
|
this.emit('connect') |
||||||
|
} |
||||||
|
|
||||||
|
onWSClose (evt) { |
||||||
|
if (this.forceClosing) { |
||||||
|
this.forceClosing = false |
||||||
|
return |
||||||
|
} |
||||||
|
console.warn('SOCKET CLOSED, code ' + evt.code + '. Reconnecting...') |
||||||
|
if (evt.code < 1000) { |
||||||
|
console.error('Bad code from socket!') |
||||||
|
// this sometimes happens for unknown reasons, code < 1000 is invalid
|
||||||
|
// location.reload()
|
||||||
|
} |
||||||
|
|
||||||
|
clearTimeout(this.reconnTimeout) |
||||||
|
this.reconnTimeout = setTimeout(() => this.init(), 2000) |
||||||
|
|
||||||
|
this.emit('disconnect', evt.code) |
||||||
|
} |
||||||
|
|
||||||
|
onWSMessage (evt) { |
||||||
|
try { |
||||||
|
switch (evt.data.charAt(0)) { |
||||||
|
case '.': |
||||||
|
// heartbeat, no-op message
|
||||||
|
break |
||||||
|
|
||||||
|
case '-': |
||||||
|
// console.log('xoff');
|
||||||
|
this.xoff = true |
||||||
|
this.autoXoffTimeout = setTimeout(() => { |
||||||
|
this.xoff = false |
||||||
|
}, 250) |
||||||
|
break |
||||||
|
|
||||||
|
case '+': |
||||||
|
// console.log('xon');
|
||||||
|
this.xoff = false |
||||||
|
clearTimeout(this.autoXoffTimeout) |
||||||
|
break |
||||||
|
|
||||||
|
default: |
||||||
|
this.emit('load') |
||||||
|
this.screen.load(evt.data) |
||||||
|
if (!this.pageShown) { |
||||||
|
window.showPage() |
||||||
|
this.pageShown = true |
||||||
|
} |
||||||
|
break |
||||||
|
} |
||||||
|
this.heartbeat() |
||||||
|
} catch (e) { |
||||||
|
console.error(e) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
canSend () { |
||||||
|
return !this.xoff |
||||||
|
} |
||||||
|
|
||||||
|
send (message) { |
||||||
|
if (window._demo) { |
||||||
|
if (typeof window.demoInterface !== 'undefined') { |
||||||
|
demo.input(message) |
||||||
|
} else { |
||||||
|
console.log(`TX: ${JSON.stringify(message)}`) |
||||||
|
} |
||||||
|
return true // Simulate success
|
||||||
|
} |
||||||
|
if (this.xoff) { |
||||||
|
// TODO queue
|
||||||
|
console.log("Can't send, flood control.") |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
if (!this.ws) return false // for dry testing
|
||||||
|
if (this.ws.readyState !== 1) { |
||||||
|
console.error('Socket not ready') |
||||||
|
return false |
||||||
|
} |
||||||
|
if (typeof message != 'string') { |
||||||
|
message = JSON.stringify(message) |
||||||
|
} |
||||||
|
this.ws.send(message) |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
/** Safely close the socket */ |
||||||
|
closeSocket () { |
||||||
|
if (this.ws) { |
||||||
|
this.forceClosing = true |
||||||
|
if (this.ws.readyState === 1) this.ws.close() |
||||||
|
this.ws = null |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
init () { |
||||||
|
if (window._demo) { |
||||||
|
if (typeof window.demoInterface === 'undefined') { |
||||||
|
window.alert('Demoing non-demo build!') // this will catch mistakes when deploying to the website
|
||||||
|
} else { |
||||||
|
demo.init(this.screen) |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
clearTimeout(this.reconnTimeout) |
||||||
|
clearTimeout(this.heartbeatTimeout) |
||||||
|
|
||||||
|
this.closeSocket() |
||||||
|
|
||||||
|
this.ws = new window.WebSocket('ws://' + window._root + '/term/update.ws') |
||||||
|
this.ws.addEventListener('open', (...args) => this.onWSOpen(...args)) |
||||||
|
this.ws.addEventListener('close', (...args) => this.onWSClose(...args)) |
||||||
|
this.ws.addEventListener('message', (...args) => this.onWSMessage(...args)) |
||||||
|
console.log('Opening socket.') |
||||||
|
this.heartbeat() |
||||||
|
|
||||||
|
this.emit('open') |
||||||
|
} |
||||||
|
|
||||||
|
heartbeat () { |
||||||
|
clearTimeout(this.heartbeatTimeout) |
||||||
|
this.heartbeatTimeout = setTimeout(() => this.onHeartbeatFail(), 2000) |
||||||
|
} |
||||||
|
|
||||||
|
onHeartbeatFail () { |
||||||
|
this.closeSocket() |
||||||
|
this.emit('silence') |
||||||
|
console.error('Heartbeat lost, probing server...') |
||||||
|
clearInterval(this.pingInterval) |
||||||
|
|
||||||
|
this.pingInterval = setInterval(() => { |
||||||
|
console.log('> ping') |
||||||
|
this.emit('ping') |
||||||
|
$.get('http://' + window._root + '/system/ping', (resp, status) => { |
||||||
|
if (status === 200) { |
||||||
|
clearInterval(this.pingInterval) |
||||||
|
console.info('Server ready, opening socket…') |
||||||
|
this.emit('ping-success') |
||||||
|
this.init() |
||||||
|
// location.reload()
|
||||||
|
} else this.emit('ping-fail', status) |
||||||
|
}, { |
||||||
|
timeout: 100, |
||||||
|
loader: false // we have loader on-screen
|
||||||
|
}) |
||||||
|
}, 1000) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,468 @@ |
|||||||
|
const $ = require('../lib/chibi') |
||||||
|
const { encode2B } = require('../utils') |
||||||
|
|
||||||
|
/** |
||||||
|
* User input |
||||||
|
* |
||||||
|
* --- Rx messages: --- |
||||||
|
* S - screen content (binary encoding of the entire screen with simple compression) |
||||||
|
* T - text labels - Title and buttons, \0x01-separated |
||||||
|
* B - beep |
||||||
|
* . - heartbeat |
||||||
|
* |
||||||
|
* --- Tx messages --- |
||||||
|
* s - string |
||||||
|
* b - action button |
||||||
|
* p - mb press |
||||||
|
* r - mb release |
||||||
|
* m - mouse move |
||||||
|
*/ |
||||||
|
module.exports = function (conn, screen) { |
||||||
|
// handle for input object
|
||||||
|
let input |
||||||
|
|
||||||
|
const KEY_NAMES = { |
||||||
|
0x03: 'Cancel', |
||||||
|
0x06: 'Help', |
||||||
|
0x08: 'Backspace', |
||||||
|
0x09: 'Tab', |
||||||
|
0x0C: 'Clear', |
||||||
|
0x0D: 'Enter', |
||||||
|
0x10: 'Shift', |
||||||
|
0x11: 'Control', |
||||||
|
0x12: 'Alt', |
||||||
|
0x13: 'Pause', |
||||||
|
0x14: 'CapsLock', |
||||||
|
0x1B: 'Escape', |
||||||
|
0x20: ' ', |
||||||
|
0x21: 'PageUp', |
||||||
|
0x22: 'PageDown', |
||||||
|
0x23: 'End', |
||||||
|
0x24: 'Home', |
||||||
|
0x25: 'ArrowLeft', |
||||||
|
0x26: 'ArrowUp', |
||||||
|
0x27: 'ArrowRight', |
||||||
|
0x28: 'ArrowDown', |
||||||
|
0x29: 'Select', |
||||||
|
0x2A: 'Print', |
||||||
|
0x2B: 'Execute', |
||||||
|
0x2C: 'PrintScreen', |
||||||
|
0x2D: 'Insert', |
||||||
|
0x2E: 'Delete', |
||||||
|
0x3A: ':', |
||||||
|
0x3B: ';', |
||||||
|
0x3C: '<', |
||||||
|
0x3D: '=', |
||||||
|
0x3E: '>', |
||||||
|
0x3F: '?', |
||||||
|
0x40: '@', |
||||||
|
0x5B: 'Meta', |
||||||
|
0x5C: 'Meta', |
||||||
|
0x5D: 'ContextMenu', |
||||||
|
0x6A: 'Numpad*', |
||||||
|
0x6B: 'Numpad+', |
||||||
|
0x6D: 'Numpad-', |
||||||
|
0x6E: 'Numpad.', |
||||||
|
0x6F: 'Numpad/', |
||||||
|
0x90: 'NumLock', |
||||||
|
0x91: 'ScrollLock', |
||||||
|
0xA0: '^', |
||||||
|
0xA1: '!', |
||||||
|
0xA2: '"', |
||||||
|
0xA3: '#', |
||||||
|
0xA4: '$', |
||||||
|
0xA5: '%', |
||||||
|
0xA6: '&', |
||||||
|
0xA7: '_', |
||||||
|
0xA8: '(', |
||||||
|
0xA9: ')', |
||||||
|
0xAA: '*', |
||||||
|
0xAB: '+', |
||||||
|
0xAC: '|', |
||||||
|
0xAD: '-', |
||||||
|
0xAE: '{', |
||||||
|
0xAF: '}', |
||||||
|
0xB0: '~', |
||||||
|
0xBA: ';', |
||||||
|
0xBB: '=', |
||||||
|
0xBC: 'Numpad,', |
||||||
|
0xBD: '-', |
||||||
|
0xBE: 'Numpad,', |
||||||
|
0xC0: '`', |
||||||
|
0xC2: 'Numpad,', |
||||||
|
0xDB: '[', |
||||||
|
0xDC: '\\', |
||||||
|
0xDD: ']', |
||||||
|
0xDE: "'", |
||||||
|
0xE0: 'Meta' |
||||||
|
} |
||||||
|
// numbers 0-9
|
||||||
|
for (let i = 0x30; i <= 0x39; i++) KEY_NAMES[i] = String.fromCharCode(i) |
||||||
|
// characters A-Z
|
||||||
|
for (let i = 0x41; i <= 0x5A; i++) KEY_NAMES[i] = String.fromCharCode(i) |
||||||
|
// function F1-F20
|
||||||
|
for (let i = 0x70; i <= 0x83; i++) KEY_NAMES[i] = `F${i - 0x70 + 1}` |
||||||
|
// numpad 0-9
|
||||||
|
for (let i = 0x60; i <= 0x69; i++) KEY_NAMES[i] = `Numpad${i - 0x60}` |
||||||
|
|
||||||
|
let cfg = { |
||||||
|
np_alt: false, // Application Numpad Mode
|
||||||
|
cu_alt: false, // Application Cursors Mode
|
||||||
|
fn_alt: false, // SS3 function keys mode
|
||||||
|
mt_click: false, // Mouse click tracking
|
||||||
|
mt_move: false, // Mouse move tracking
|
||||||
|
no_keys: false, // Suppress any key / clipboard event intercepting
|
||||||
|
crlf_mode: false, // Enter sends CR LF
|
||||||
|
all_fn: false // Capture also F5, F11 and F12
|
||||||
|
} |
||||||
|
|
||||||
|
/** Fn alt choice for key message */ |
||||||
|
const fa = (alt, normal) => cfg.fn_alt ? alt : normal |
||||||
|
|
||||||
|
/** Cursor alt choice for key message */ |
||||||
|
const ca = (alt, normal) => cfg.cu_alt ? alt : normal |
||||||
|
|
||||||
|
/** Numpad alt choice for key message */ |
||||||
|
const na = (alt, normal) => cfg.np_alt ? alt : normal |
||||||
|
|
||||||
|
const keymap = { |
||||||
|
/* eslint-disable key-spacing */ |
||||||
|
'Backspace': '\x08', |
||||||
|
'Tab': '\x09', |
||||||
|
'Enter': () => cfg.crlf_mode ? '\x0d\x0a' : '\x0d', |
||||||
|
'Control+Enter': '\x0a', |
||||||
|
'Escape': '\x1b', |
||||||
|
'ArrowUp': () => ca('\x1bOA', '\x1b[A'), |
||||||
|
'ArrowDown': () => ca('\x1bOB', '\x1b[B'), |
||||||
|
'ArrowRight': () => ca('\x1bOC', '\x1b[C'), |
||||||
|
'ArrowLeft': () => ca('\x1bOD', '\x1b[D'), |
||||||
|
'Home': () => ca('\x1bOH', fa('\x1b[H', '\x1b[1~')), |
||||||
|
'Insert': '\x1b[2~', |
||||||
|
'Delete': '\x1b[3~', |
||||||
|
'End': () => ca('\x1bOF', fa('\x1b[F', '\x1b[4~')), |
||||||
|
'PageUp': '\x1b[5~', |
||||||
|
'PageDown': '\x1b[6~', |
||||||
|
'F1': () => fa('\x1bOP', '\x1b[11~'), |
||||||
|
'F2': () => fa('\x1bOQ', '\x1b[12~'), |
||||||
|
'F3': () => fa('\x1bOR', '\x1b[13~'), |
||||||
|
'F4': () => fa('\x1bOS', '\x1b[14~'), |
||||||
|
'F5': '\x1b[15~', // note the disconnect
|
||||||
|
'F6': '\x1b[17~', |
||||||
|
'F7': '\x1b[18~', |
||||||
|
'F8': '\x1b[19~', |
||||||
|
'F9': '\x1b[20~', |
||||||
|
'F10': '\x1b[21~', // note the disconnect
|
||||||
|
'F11': '\x1b[23~', |
||||||
|
'F12': '\x1b[24~', |
||||||
|
'Shift+F1': () => fa('\x1bO1;2P', '\x1b[25~'), |
||||||
|
'Shift+F2': () => fa('\x1bO1;2Q', '\x1b[26~'), // note the disconnect
|
||||||
|
'Shift+F3': () => fa('\x1bO1;2R', '\x1b[28~'), |
||||||
|
'Shift+F4': () => fa('\x1bO1;2S', '\x1b[29~'), // note the disconnect
|
||||||
|
'Shift+F5': () => fa('\x1b[15;2~', '\x1b[31~'), |
||||||
|
'Shift+F6': () => fa('\x1b[17;2~', '\x1b[32~'), |
||||||
|
'Shift+F7': () => fa('\x1b[18;2~', '\x1b[33~'), |
||||||
|
'Shift+F8': () => fa('\x1b[19;2~', '\x1b[34~'), |
||||||
|
'Shift+F9': () => fa('\x1b[20;2~', '\x1b[35~'), // 35-38 are not standard - but what is?
|
||||||
|
'Shift+F10': () => fa('\x1b[21;2~', '\x1b[36~'), |
||||||
|
'Shift+F11': () => fa('\x1b[22;2~', '\x1b[37~'), |
||||||
|
'Shift+F12': () => fa('\x1b[23;2~', '\x1b[38~'), |
||||||
|
'Numpad0': () => na('\x1bOp', '0'), |
||||||
|
'Numpad1': () => na('\x1bOq', '1'), |
||||||
|
'Numpad2': () => na('\x1bOr', '2'), |
||||||
|
'Numpad3': () => na('\x1bOs', '3'), |
||||||
|
'Numpad4': () => na('\x1bOt', '4'), |
||||||
|
'Numpad5': () => na('\x1bOu', '5'), |
||||||
|
'Numpad6': () => na('\x1bOv', '6'), |
||||||
|
'Numpad7': () => na('\x1bOw', '7'), |
||||||
|
'Numpad8': () => na('\x1bOx', '8'), |
||||||
|
'Numpad9': () => na('\x1bOy', '9'), |
||||||
|
'Numpad*': () => na('\x1bOR', '*'), |
||||||
|
'Numpad+': () => na('\x1bOl', '+'), |
||||||
|
'Numpad-': () => na('\x1bOS', '-'), |
||||||
|
'Numpad.': () => na('\x1bOn', '.'), |
||||||
|
'Numpad/': () => na('\x1bOQ', '/'), |
||||||
|
// we don't implement numlock key (should change in numpad_alt mode,
|
||||||
|
// but it's even more useless than the rest and also has the side
|
||||||
|
// effect of changing the user's numlock state)
|
||||||
|
|
||||||
|
// shortcuts
|
||||||
|
'Control+]': '\x1b', // alternate way to enter ESC
|
||||||
|
'Control+\\': '\x1c', |
||||||
|
'Control+[': '\x1d', |
||||||
|
'Control+^': '\x1e', |
||||||
|
'Control+_': '\x1f', |
||||||
|
|
||||||
|
// extra controls
|
||||||
|
'Control+ArrowLeft': '\x1f[1;5D', |
||||||
|
'Control+ArrowRight': '\x1f[1;5C', |
||||||
|
'Control+ArrowUp': '\x1f[1;5A', |
||||||
|
'Control+ArrowDown': '\x1f[1;5B', |
||||||
|
'Control+Home': '\x1f[1;5H', |
||||||
|
'Control+End': '\x1f[1;5F', |
||||||
|
|
||||||
|
// extra shift controls
|
||||||
|
'Shift+ArrowLeft': '\x1f[1;2D', |
||||||
|
'Shift+ArrowRight': '\x1f[1;2C', |
||||||
|
'Shift+ArrowUp': '\x1f[1;2A', |
||||||
|
'Shift+ArrowDown': '\x1f[1;2B', |
||||||
|
'Shift+Home': '\x1f[1;2H', |
||||||
|
'Shift+End': '\x1f[1;2F', |
||||||
|
|
||||||
|
// macOS text editing commands
|
||||||
|
'Alt+ArrowLeft': '\x1bb', // ⌥← to go back a word (^[b)
|
||||||
|
'Alt+ArrowRight': '\x1bf', // ⌥→ to go forward one word (^[f)
|
||||||
|
'Meta+ArrowLeft': '\x01', // ⌘← to go to the beginning of a line (^A)
|
||||||
|
'Meta+ArrowRight': '\x05', // ⌘→ to go to the end of a line (^E)
|
||||||
|
'Alt+Backspace': '\x17', // ⌥⌫ to delete a word (^W)
|
||||||
|
'Meta+Backspace': '\x15', // ⌘⌫ to delete to the beginning of a line (^U)
|
||||||
|
|
||||||
|
// copy to clipboard
|
||||||
|
'Control+Shift+C' () { |
||||||
|
screen.copySelectionToClipboard() |
||||||
|
}, |
||||||
|
'Control+Insert' () { |
||||||
|
screen.copySelectionToClipboard() |
||||||
|
}, |
||||||
|
|
||||||
|
// toggle debug mode
|
||||||
|
'Control+F12' () { |
||||||
|
screen.window.debug ^= 1 |
||||||
|
} |
||||||
|
/* eslint-enable key-spacing */ |
||||||
|
} |
||||||
|
|
||||||
|
// ctrl+[A-Z] sent as simple low ASCII codes
|
||||||
|
for (let i = 1; i <= 26; i++) { |
||||||
|
keymap[`Control+${String.fromCharCode(0x40 + i)}`] = String.fromCharCode(i) |
||||||
|
} |
||||||
|
|
||||||
|
/** Send a literal message */ |
||||||
|
function sendString (str) { |
||||||
|
return conn.send('s' + str) |
||||||
|
} |
||||||
|
|
||||||
|
/** Send a button event */ |
||||||
|
function sendButton (n) { |
||||||
|
conn.send('b' + String.fromCharCode(n)) |
||||||
|
} |
||||||
|
|
||||||
|
const shouldAcceptEvent = function () { |
||||||
|
if (cfg.no_keys) return false |
||||||
|
if (document.activeElement instanceof window.HTMLTextAreaElement) return false |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
const keyBlacklist = [ |
||||||
|
'F5', 'F11', 'F12', 'Shift+F5' |
||||||
|
] |
||||||
|
|
||||||
|
let softModifiers = { |
||||||
|
alt: false, |
||||||
|
ctrl: false, |
||||||
|
meta: false, |
||||||
|
shift: false |
||||||
|
} |
||||||
|
|
||||||
|
const handleKeyDown = function (e) { |
||||||
|
if (!shouldAcceptEvent()) return |
||||||
|
|
||||||
|
let modifiers = [] |
||||||
|
// sorted alphabetically
|
||||||
|
if (e.altKey || softModifiers.alt) modifiers.push('Alt') |
||||||
|
if (e.ctrlKey || softModifiers.ctrl) modifiers.push('Control') |
||||||
|
if (e.metaKey || softModifiers.meta) modifiers.push('Meta') |
||||||
|
if (e.shiftKey || softModifiers.shift) modifiers.push('Shift') |
||||||
|
|
||||||
|
let key = KEY_NAMES[e.which] || e.key |
||||||
|
|
||||||
|
// ignore clipboard events
|
||||||
|
if ((e.ctrlKey || e.metaKey) && key === 'V') return |
||||||
|
|
||||||
|
let binding = null |
||||||
|
|
||||||
|
for (let name in keymap) { |
||||||
|
let itemModifiers = name.split('+') |
||||||
|
let itemKey = itemModifiers.pop() |
||||||
|
|
||||||
|
if (itemKey === key && itemModifiers.sort().join() === modifiers.join()) { |
||||||
|
if (keyBlacklist.includes(name) && !cfg.all_fn) continue |
||||||
|
binding = keymap[name] |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (binding) { |
||||||
|
if (binding instanceof Function) binding = binding() |
||||||
|
e.preventDefault() |
||||||
|
if (typeof binding === 'string') { |
||||||
|
sendString(binding) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** Bind/rebind key messages */ |
||||||
|
function initKeys ({ allFn }) { |
||||||
|
// This takes care of text characters typed
|
||||||
|
window.addEventListener('keypress', function (evt) { |
||||||
|
if (!shouldAcceptEvent()) return |
||||||
|
if (evt.ctrlKey || evt.metaKey) return |
||||||
|
|
||||||
|
let str = '' |
||||||
|
if (evt.key && evt.key.length === 1) str = evt.key |
||||||
|
else if (evt.which && evt.which !== 229) str = String.fromCodePoint(evt.which) |
||||||
|
|
||||||
|
if (str.length > 0 && str.charCodeAt(0) >= 32) { |
||||||
|
// prevent space from scrolling
|
||||||
|
if (evt.which === 32) evt.preventDefault() |
||||||
|
sendString(str) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown) |
||||||
|
window.addEventListener('copy', e => { |
||||||
|
if (!shouldAcceptEvent()) return |
||||||
|
let selectedText = screen.getSelectedText() |
||||||
|
if (selectedText) { |
||||||
|
e.preventDefault() |
||||||
|
e.clipboardData.setData('text/plain', selectedText) |
||||||
|
} |
||||||
|
}) |
||||||
|
window.addEventListener('paste', e => { |
||||||
|
if (!shouldAcceptEvent()) return |
||||||
|
e.preventDefault() |
||||||
|
let string = e.clipboardData.getData('text/plain') |
||||||
|
if (string.includes('\n') || string.length > 90) { |
||||||
|
if (!input.termUpload) console.error('input.termUpload is undefined') |
||||||
|
input.termUpload.setContent(string) |
||||||
|
input.termUpload.open() |
||||||
|
} else { |
||||||
|
// simple string, just paste it
|
||||||
|
if (screen.bracketedPaste) string = `\x1b[200~${string}\x1b[201~` |
||||||
|
sendString(string) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
cfg.all_fn = allFn |
||||||
|
} |
||||||
|
|
||||||
|
// mouse button states
|
||||||
|
let mb1 = 0 |
||||||
|
let mb2 = 0 |
||||||
|
let mb3 = 0 |
||||||
|
|
||||||
|
/** Init the Input module */ |
||||||
|
function init (opts) { |
||||||
|
initKeys(opts) |
||||||
|
|
||||||
|
// Button presses
|
||||||
|
$('#action-buttons button').forEach(s => { |
||||||
|
s.addEventListener('click', function (evt) { |
||||||
|
sendButton(+this.dataset['n']) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
// global mouse state tracking - for motion reporting
|
||||||
|
window.addEventListener('mousedown', evt => { |
||||||
|
if (evt.button === 0) mb1 = 1 |
||||||
|
if (evt.button === 1) mb2 = 1 |
||||||
|
if (evt.button === 2) mb3 = 1 |
||||||
|
}) |
||||||
|
|
||||||
|
window.addEventListener('mouseup', evt => { |
||||||
|
if (evt.button === 0) mb1 = 0 |
||||||
|
if (evt.button === 1) mb2 = 0 |
||||||
|
if (evt.button === 2) mb3 = 0 |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// record modifier keys
|
||||||
|
// bits: Meta, Alt, Shift, Ctrl
|
||||||
|
let modifiers = 0b0000 |
||||||
|
|
||||||
|
window.addEventListener('keydown', e => { |
||||||
|
if (e.ctrlKey) modifiers |= 1 |
||||||
|
if (e.shiftKey) modifiers |= 2 |
||||||
|
if (e.altKey) modifiers |= 4 |
||||||
|
if (e.metaKey) modifiers |= 8 |
||||||
|
}) |
||||||
|
window.addEventListener('keyup', e => { |
||||||
|
modifiers = 0 |
||||||
|
if (e.ctrlKey) modifiers |= 1 |
||||||
|
if (e.shiftKey) modifiers |= 2 |
||||||
|
if (e.altKey) modifiers |= 4 |
||||||
|
if (e.metaKey) modifiers |= 8 |
||||||
|
}) |
||||||
|
|
||||||
|
/** Prepare modifiers byte for mouse message */ |
||||||
|
function packModifiersForMouse () { |
||||||
|
return modifiers |
||||||
|
} |
||||||
|
|
||||||
|
input = { |
||||||
|
/** Init the Input module */ |
||||||
|
init, |
||||||
|
|
||||||
|
/** Send a literal string message */ |
||||||
|
sendString, |
||||||
|
|
||||||
|
/** Enable alternate key modes (cursors, numpad, fn) */ |
||||||
|
setAlts: function (cu, np, fn, crlf) { |
||||||
|
if (cfg.cu_alt !== cu || cfg.np_alt !== np || cfg.fn_alt !== fn || cfg.crlf_mode !== crlf) { |
||||||
|
cfg.cu_alt = cu |
||||||
|
cfg.np_alt = np |
||||||
|
cfg.fn_alt = fn |
||||||
|
cfg.crlf_mode = crlf |
||||||
|
} |
||||||
|
}, |
||||||
|
|
||||||
|
setMouseMode (click, move) { |
||||||
|
cfg.mt_click = click |
||||||
|
cfg.mt_move = move |
||||||
|
}, |
||||||
|
|
||||||
|
// Mouse events
|
||||||
|
onMouseMove (x, y) { |
||||||
|
if (!cfg.mt_move) return |
||||||
|
const b = mb1 ? 1 : mb2 ? 2 : mb3 ? 3 : 0 |
||||||
|
const m = packModifiersForMouse() |
||||||
|
conn.send('m' + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m)) |
||||||
|
}, |
||||||
|
|
||||||
|
onMouseDown (x, y, b) { |
||||||
|
if (!cfg.mt_click) return |
||||||
|
if (b > 3 || b < 1) return |
||||||
|
const m = packModifiersForMouse() |
||||||
|
conn.send('p' + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m)) |
||||||
|
}, |
||||||
|
|
||||||
|
onMouseUp (x, y, b) { |
||||||
|
if (!cfg.mt_click) return |
||||||
|
if (b > 3 || b < 1) return |
||||||
|
const m = packModifiersForMouse() |
||||||
|
conn.send('r' + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m)) |
||||||
|
// console.log("B ",b," M ",m);
|
||||||
|
}, |
||||||
|
|
||||||
|
onMouseWheel (x, y, dir) { |
||||||
|
if (!cfg.mt_click) return |
||||||
|
// -1 ... btn 4 (away from user)
|
||||||
|
// +1 ... btn 5 (towards user)
|
||||||
|
const m = packModifiersForMouse() |
||||||
|
const b = (dir < 0 ? 4 : 5) |
||||||
|
conn.send('p' + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m)) |
||||||
|
// console.log("B ",b," M ",m);
|
||||||
|
}, |
||||||
|
|
||||||
|
/** |
||||||
|
* Prevent capturing keys. This is used for text input |
||||||
|
* modals on the terminal screen |
||||||
|
*/ |
||||||
|
blockKeys (yes) { |
||||||
|
cfg.no_keys = yes |
||||||
|
}, |
||||||
|
|
||||||
|
handleKeyDown, |
||||||
|
softModifiers |
||||||
|
} |
||||||
|
return input |
||||||
|
} |
@ -0,0 +1,578 @@ |
|||||||
|
const EventEmitter = require('events') |
||||||
|
const $ = require('../lib/chibi') |
||||||
|
const { mk, qs } = require('../utils') |
||||||
|
const notify = require('../notif') |
||||||
|
const ScreenParser = require('./screen_parser') |
||||||
|
const ScreenRenderer = require('./screen_renderer') |
||||||
|
|
||||||
|
module.exports = class TermScreen extends EventEmitter { |
||||||
|
constructor () { |
||||||
|
super() |
||||||
|
|
||||||
|
this.canvas = mk('canvas') |
||||||
|
this.ctx = this.canvas.getContext('2d') |
||||||
|
|
||||||
|
this.parser = new ScreenParser(this) |
||||||
|
this.renderer = new ScreenRenderer(this) |
||||||
|
|
||||||
|
// debug screen handle
|
||||||
|
this._debug = null |
||||||
|
|
||||||
|
if ('AudioContext' in window || 'webkitAudioContext' in window) { |
||||||
|
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)() |
||||||
|
} else { |
||||||
|
console.warn('No AudioContext!') |
||||||
|
} |
||||||
|
|
||||||
|
// dummy. Handle for Input
|
||||||
|
this.input = new Proxy({}, { |
||||||
|
get () { |
||||||
|
return () => console.warn('TermScreen#input not set!') |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
this.cursor = { |
||||||
|
x: 0, |
||||||
|
y: 0, |
||||||
|
blinking: true, |
||||||
|
visible: true, |
||||||
|
hanging: false, |
||||||
|
style: 'block' |
||||||
|
} |
||||||
|
|
||||||
|
this._window = { |
||||||
|
width: 0, |
||||||
|
height: 0, |
||||||
|
devicePixelRatio: 1, |
||||||
|
fontFamily: '"DejaVu Sans Mono", "Liberation Mono", "Inconsolata", "Menlo", monospace', |
||||||
|
fontSize: 20, |
||||||
|
gridScaleX: 1.0, |
||||||
|
gridScaleY: 1.2, |
||||||
|
fitIntoWidth: 0, |
||||||
|
fitIntoHeight: 0, |
||||||
|
debug: false, |
||||||
|
graphics: 0, |
||||||
|
statusScreen: null |
||||||
|
} |
||||||
|
|
||||||
|
// scaling caused by fitIntoWidth/fitIntoHeight
|
||||||
|
this._windowScale = 1 |
||||||
|
|
||||||
|
// properties of this.window that require updating size and redrawing
|
||||||
|
this.windowState = { |
||||||
|
width: 0, |
||||||
|
height: 0, |
||||||
|
devicePixelRatio: 0, |
||||||
|
gridScaleX: 0, |
||||||
|
gridScaleY: 0, |
||||||
|
fontFamily: '', |
||||||
|
fontSize: 0, |
||||||
|
fitIntoWidth: 0, |
||||||
|
fitIntoHeight: 0 |
||||||
|
} |
||||||
|
|
||||||
|
// current selection
|
||||||
|
this.selection = { |
||||||
|
// when false, this will prevent selection in favor of mouse events,
|
||||||
|
// though alt can be held to override it
|
||||||
|
selectable: true, |
||||||
|
|
||||||
|
// selection start and end (x, y) tuples
|
||||||
|
start: [0, 0], |
||||||
|
end: [0, 0] |
||||||
|
} |
||||||
|
|
||||||
|
// mouse features
|
||||||
|
this.mouseMode = { clicks: false, movement: false } |
||||||
|
|
||||||
|
// make writing to window update size and draw
|
||||||
|
const self = this |
||||||
|
this.window = new Proxy(this._window, { |
||||||
|
set (target, key, value, receiver) { |
||||||
|
target[key] = value |
||||||
|
self.scheduleSizeUpdate() |
||||||
|
self.renderer.scheduleDraw(`window:${key}=${value}`) |
||||||
|
self.emit(`update-window:${key}`, value) |
||||||
|
return true |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
this.bracketedPaste = false |
||||||
|
this.blinkingCellCount = 0 |
||||||
|
this.reverseVideo = false |
||||||
|
|
||||||
|
this.screen = [] |
||||||
|
this.screenFG = [] |
||||||
|
this.screenBG = [] |
||||||
|
this.screenAttrs = [] |
||||||
|
|
||||||
|
let selecting = false |
||||||
|
|
||||||
|
let selectStart = (x, y) => { |
||||||
|
if (selecting) return |
||||||
|
selecting = true |
||||||
|
this.selection.start = this.selection.end = this.screenToGrid(x, y, true) |
||||||
|
this.renderer.scheduleDraw('select-start') |
||||||
|
} |
||||||
|
|
||||||
|
let selectMove = (x, y) => { |
||||||
|
if (!selecting) return |
||||||
|
this.selection.end = this.screenToGrid(x, y, true) |
||||||
|
this.renderer.scheduleDraw('select-move') |
||||||
|
} |
||||||
|
|
||||||
|
let selectEnd = (x, y) => { |
||||||
|
if (!selecting) return |
||||||
|
selecting = false |
||||||
|
this.selection.end = this.screenToGrid(x, y, true) |
||||||
|
this.renderer.scheduleDraw('select-end') |
||||||
|
Object.assign(this.selection, this.getNormalizedSelection()) |
||||||
|
} |
||||||
|
|
||||||
|
// bind event listeners
|
||||||
|
|
||||||
|
this.canvas.addEventListener('mousedown', e => { |
||||||
|
if ((this.selection.selectable || e.altKey) && e.button === 0) { |
||||||
|
selectStart(e.offsetX, e.offsetY) |
||||||
|
} else { |
||||||
|
this.input.onMouseDown(...this.screenToGrid(e.offsetX, e.offsetY), |
||||||
|
e.button + 1) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
window.addEventListener('mousemove', e => { |
||||||
|
selectMove(e.offsetX, e.offsetY) |
||||||
|
}) |
||||||
|
|
||||||
|
window.addEventListener('mouseup', e => { |
||||||
|
selectEnd(e.offsetX, e.offsetY) |
||||||
|
}) |
||||||
|
|
||||||
|
// touch event listeners
|
||||||
|
|
||||||
|
let touchPosition = null |
||||||
|
let touchDownTime = 0 |
||||||
|
let touchSelectMinTime = 500 |
||||||
|
let touchDidMove = false |
||||||
|
|
||||||
|
let getTouchPositionOffset = touch => { |
||||||
|
let rect = this.canvas.getBoundingClientRect() |
||||||
|
return [touch.clientX - rect.left, touch.clientY - rect.top] |
||||||
|
} |
||||||
|
|
||||||
|
this.canvas.addEventListener('touchstart', e => { |
||||||
|
touchPosition = getTouchPositionOffset(e.touches[0]) |
||||||
|
touchDidMove = false |
||||||
|
touchDownTime = Date.now() |
||||||
|
}, { passive: true }) |
||||||
|
|
||||||
|
this.canvas.addEventListener('touchmove', e => { |
||||||
|
touchPosition = getTouchPositionOffset(e.touches[0]) |
||||||
|
|
||||||
|
if (!selecting && touchDidMove === false) { |
||||||
|
if (touchDownTime < Date.now() - touchSelectMinTime) { |
||||||
|
selectStart(...touchPosition) |
||||||
|
} |
||||||
|
} else if (selecting) { |
||||||
|
e.preventDefault() |
||||||
|
selectMove(...touchPosition) |
||||||
|
} |
||||||
|
|
||||||
|
touchDidMove = true |
||||||
|
}) |
||||||
|
|
||||||
|
this.canvas.addEventListener('touchend', e => { |
||||||
|
if (e.touches[0]) { |
||||||
|
touchPosition = getTouchPositionOffset(e.touches[0]) |
||||||
|
} |
||||||
|
|
||||||
|
if (selecting) { |
||||||
|
e.preventDefault() |
||||||
|
selectEnd(...touchPosition) |
||||||
|
|
||||||
|
// selection ended; show touch select menu
|
||||||
|
let touchSelectMenu = qs('#touch-select-menu') |
||||||
|
touchSelectMenu.classList.add('open') |
||||||
|
let rect = touchSelectMenu.getBoundingClientRect() |
||||||
|
|
||||||
|
// use middle position for x and one line above for y
|
||||||
|
let selectionPos = this.gridToScreen( |
||||||
|
(this.selection.start[0] + this.selection.end[0]) / 2, |
||||||
|
this.selection.start[1] - 1 |
||||||
|
) |
||||||
|
selectionPos[0] -= rect.width / 2 |
||||||
|
selectionPos[1] -= rect.height / 2 |
||||||
|
touchSelectMenu.style.transform = `translate(${selectionPos[0]}px, ${ |
||||||
|
selectionPos[1]}px)` |
||||||
|
} |
||||||
|
|
||||||
|
if (!touchDidMove) { |
||||||
|
this.emit('tap', Object.assign(e, { |
||||||
|
x: touchPosition[0], |
||||||
|
y: touchPosition[1] |
||||||
|
})) |
||||||
|
} |
||||||
|
|
||||||
|
touchPosition = null |
||||||
|
}) |
||||||
|
|
||||||
|
this.on('tap', e => { |
||||||
|
if (this.selection.start[0] !== this.selection.end[0] || |
||||||
|
this.selection.start[1] !== this.selection.end[1]) { |
||||||
|
// selection is not empty
|
||||||
|
// reset selection
|
||||||
|
this.selection.start = this.selection.end = [0, 0] |
||||||
|
qs('#touch-select-menu').classList.remove('open') |
||||||
|
this.renderer.scheduleDraw('select-reset') |
||||||
|
} else { |
||||||
|
e.preventDefault() |
||||||
|
this.emit('open-soft-keyboard') |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
$.ready(() => { |
||||||
|
let copyButton = qs('#touch-select-copy-btn') |
||||||
|
if (copyButton) { |
||||||
|
copyButton.addEventListener('click', () => { |
||||||
|
this.copySelectionToClipboard() |
||||||
|
}) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
this.canvas.addEventListener('mousemove', e => { |
||||||
|
if (!selecting) { |
||||||
|
this.input.onMouseMove(...this.screenToGrid(e.offsetX, e.offsetY)) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
this.canvas.addEventListener('mouseup', e => { |
||||||
|
if (!selecting) { |
||||||
|
this.input.onMouseUp(...this.screenToGrid(e.offsetX, e.offsetY), |
||||||
|
e.button + 1) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
this.canvas.addEventListener('wheel', e => { |
||||||
|
if (this.mouseMode.clicks) { |
||||||
|
this.input.onMouseWheel(...this.screenToGrid(e.offsetX, e.offsetY), |
||||||
|
e.deltaY > 0 ? 1 : -1) |
||||||
|
|
||||||
|
// prevent page scrolling
|
||||||
|
e.preventDefault() |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
this.canvas.addEventListener('contextmenu', e => { |
||||||
|
if (this.mouseMode.clicks) { |
||||||
|
// prevent mouse keys getting stuck
|
||||||
|
e.preventDefault() |
||||||
|
} |
||||||
|
selectEnd(e.offsetX, e.offsetY) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Schedule a size update in the next millisecond |
||||||
|
*/ |
||||||
|
scheduleSizeUpdate () { |
||||||
|
clearTimeout(this._scheduledSizeUpdate) |
||||||
|
this._scheduledSizeUpdate = setTimeout(() => this.updateSize(), 1) |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns a CSS font string with this TermScreen's font settings and the |
||||||
|
* font modifiers. |
||||||
|
* @param {Object} modifiers |
||||||
|
* @param {string} [modifiers.style] - the font style |
||||||
|
* @param {string} [modifiers.weight] - the font weight |
||||||
|
* @returns {string} a CSS font string |
||||||
|
*/ |
||||||
|
getFont (modifiers = {}) { |
||||||
|
let fontStyle = modifiers.style || 'normal' |
||||||
|
let fontWeight = modifiers.weight || 'normal' |
||||||
|
return `${fontStyle} normal ${fontWeight} ${this.window.fontSize}px ${this.window.fontFamily}` |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Converts screen coordinates to grid coordinates. |
||||||
|
* @param {number} x - x in pixels |
||||||
|
* @param {number} y - y in pixels |
||||||
|
* @param {boolean} rounded - whether to round the coord, used for select highlighting |
||||||
|
* @returns {number[]} a tuple of (x, y) in cells |
||||||
|
*/ |
||||||
|
screenToGrid (x, y, rounded = false) { |
||||||
|
let cellSize = this.getCellSize() |
||||||
|
|
||||||
|
return [ |
||||||
|
Math.floor((x + (rounded ? cellSize.width / 2 : 0)) / cellSize.width), |
||||||
|
Math.floor(y / cellSize.height) |
||||||
|
] |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Converts grid coordinates to screen coordinates. |
||||||
|
* @param {number} x - x in cells |
||||||
|
* @param {number} y - y in cells |
||||||
|
* @param {boolean} [withScale] - when true, will apply window scale |
||||||
|
* @returns {number[]} a tuple of (x, y) in pixels |
||||||
|
*/ |
||||||
|
gridToScreen (x, y, withScale = false) { |
||||||
|
let cellSize = this.getCellSize() |
||||||
|
|
||||||
|
return [x * cellSize.width, y * cellSize.height].map(v => withScale ? v * this._windowScale : v) |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* The character size, used for calculating the cell size. The space character |
||||||
|
* is used for measuring. |
||||||
|
* @returns {Object} the character size with `width` and `height` in pixels |
||||||
|
*/ |
||||||
|
getCharSize () { |
||||||
|
this.ctx.font = this.getFont() |
||||||
|
|
||||||
|
return { |
||||||
|
width: Math.floor(this.ctx.measureText(' ').width), |
||||||
|
height: this.window.fontSize |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* The cell size, which is the character size multiplied by the grid scale. |
||||||
|
* @returns {Object} the cell size with `width` and `height` in pixels |
||||||
|
*/ |
||||||
|
getCellSize () { |
||||||
|
let charSize = this.getCharSize() |
||||||
|
|
||||||
|
return { |
||||||
|
width: Math.ceil(charSize.width * this.window.gridScaleX), |
||||||
|
height: Math.ceil(charSize.height * this.window.gridScaleY) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Updates the canvas size if it changed |
||||||
|
*/ |
||||||
|
updateSize () { |
||||||
|
// see below (this is just updating it)
|
||||||
|
this._window.devicePixelRatio = Math.round(this._windowScale * (window.devicePixelRatio || 1) * 2) / 2 |
||||||
|
|
||||||
|
let didChange = false |
||||||
|
for (let key in this.windowState) { |
||||||
|
if (this.windowState.hasOwnProperty(key) && this.windowState[key] !== this.window[key]) { |
||||||
|
didChange = true |
||||||
|
this.windowState[key] = this.window[key] |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (didChange) { |
||||||
|
const { |
||||||
|
width, |
||||||
|
height, |
||||||
|
fitIntoWidth, |
||||||
|
fitIntoHeight |
||||||
|
} = this.window |
||||||
|
const cellSize = this.getCellSize() |
||||||
|
|
||||||
|
// real height of the canvas element in pixels
|
||||||
|
let realWidth = width * cellSize.width |
||||||
|
let realHeight = height * cellSize.height |
||||||
|
|
||||||
|
if (fitIntoWidth && fitIntoHeight) { |
||||||
|
let terminalAspect = realWidth / realHeight |
||||||
|
let fitAspect = fitIntoWidth / fitIntoHeight |
||||||
|
|
||||||
|
if (terminalAspect < fitAspect) { |
||||||
|
// align heights
|
||||||
|
realHeight = fitIntoHeight |
||||||
|
realWidth = realHeight * terminalAspect |
||||||
|
} else { |
||||||
|
// align widths
|
||||||
|
realWidth = fitIntoWidth |
||||||
|
realHeight = realWidth / terminalAspect |
||||||
|
} |
||||||
|
} else if (fitIntoWidth) { |
||||||
|
realHeight = fitIntoWidth / (realWidth / realHeight) |
||||||
|
realWidth = fitIntoWidth |
||||||
|
} else if (fitIntoHeight) { |
||||||
|
realWidth = fitIntoHeight * (realWidth / realHeight) |
||||||
|
realHeight = fitIntoHeight |
||||||
|
} |
||||||
|
|
||||||
|
// store new window scale
|
||||||
|
this._windowScale = realWidth / (width * cellSize.width) |
||||||
|
|
||||||
|
// the DPR must be rounded to a very nice value to prevent gaps between cells
|
||||||
|
let devicePixelRatio = this._window.devicePixelRatio = Math.round(this._windowScale * (window.devicePixelRatio || 1) * 2) / 2 |
||||||
|
|
||||||
|
this.canvas.width = width * devicePixelRatio * cellSize.width |
||||||
|
this.canvas.style.width = `${realWidth}px` |
||||||
|
this.canvas.height = height * devicePixelRatio * cellSize.height |
||||||
|
this.canvas.style.height = `${realHeight}px` |
||||||
|
|
||||||
|
// the screen has been cleared (by changing canvas width)
|
||||||
|
this.renderer.resetDrawn() |
||||||
|
|
||||||
|
// draw immediately; the canvas shouldn't flash
|
||||||
|
this.renderer.draw('update-size') |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns a normalized version of the current selection, such that `start` |
||||||
|
* is always before `end`. |
||||||
|
* @returns {Object} the normalized selection, with `start` and `end` |
||||||
|
*/ |
||||||
|
getNormalizedSelection () { |
||||||
|
let { start, end } = this.selection |
||||||
|
// if the start line is after the end line, or if they're both on the same
|
||||||
|
// line but the start column comes after the end column, swap
|
||||||
|
if (start[1] > end[1] || (start[1] === end[1] && start[0] > end[0])) { |
||||||
|
[start, end] = [end, start] |
||||||
|
} |
||||||
|
return { start, end } |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns whether or not a given cell is in the current selection. |
||||||
|
* @param {number} col - the column (x) |
||||||
|
* @param {number} line - the line (y) |
||||||
|
* @returns {boolean} |
||||||
|
*/ |
||||||
|
isInSelection (col, line) { |
||||||
|
let { start, end } = this.getNormalizedSelection() |
||||||
|
let colAfterStart = start[0] <= col |
||||||
|
let colBeforeEnd = col < end[0] |
||||||
|
let onStartLine = line === start[1] |
||||||
|
let onEndLine = line === end[1] |
||||||
|
|
||||||
|
if (onStartLine && onEndLine) return colAfterStart && colBeforeEnd |
||||||
|
else if (onStartLine) return colAfterStart |
||||||
|
else if (onEndLine) return colBeforeEnd |
||||||
|
else return start[1] < line && line < end[1] |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Sweeps for selected cells and joins them in a multiline string. |
||||||
|
* @returns {string} the selection |
||||||
|
*/ |
||||||
|
getSelectedText () { |
||||||
|
const screenLength = this.window.width * this.window.height |
||||||
|
let lines = [] |
||||||
|
let previousLineIndex = -1 |
||||||
|
|
||||||
|
for (let cell = 0; cell < screenLength; cell++) { |
||||||
|
let x = cell % this.window.width |
||||||
|
let y = Math.floor(cell / this.window.width) |
||||||
|
|
||||||
|
if (this.isInSelection(x, y)) { |
||||||
|
if (previousLineIndex !== y) { |
||||||
|
previousLineIndex = y |
||||||
|
lines.push('') |
||||||
|
} |
||||||
|
lines[lines.length - 1] += this.screen[cell] |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return lines.join('\n') |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Copies the selection to clipboard and creates a notification balloon. |
||||||
|
*/ |
||||||
|
copySelectionToClipboard () { |
||||||
|
let selectedText = this.getSelectedText() |
||||||
|
// don't copy anything if nothing is selected
|
||||||
|
if (!selectedText) return |
||||||
|
let textarea = mk('textarea') |
||||||
|
document.body.appendChild(textarea) |
||||||
|
textarea.value = selectedText |
||||||
|
textarea.select() |
||||||
|
if (document.execCommand('copy')) { |
||||||
|
notify.show('Copied to clipboard') |
||||||
|
} else { |
||||||
|
notify.show('Failed to copy') |
||||||
|
} |
||||||
|
document.body.removeChild(textarea) |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Shows an actual notification (if possible) or a notification balloon. |
||||||
|
* @param {string} text - the notification content |
||||||
|
*/ |
||||||
|
showNotification (text) { |
||||||
|
console.info(`Notification: ${text}`) |
||||||
|
if (window.Notification && window.Notification.permission === 'granted') { |
||||||
|
let notification = new window.Notification('ESPTerm', { |
||||||
|
body: text |
||||||
|
}) |
||||||
|
notification.addEventListener('click', () => window.focus()) |
||||||
|
} else { |
||||||
|
if (window.Notification && window.Notification.permission !== 'denied') { |
||||||
|
window.Notification.requestPermission() |
||||||
|
} else { |
||||||
|
// Fallback using the built-in notification balloon
|
||||||
|
notify.show(text) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Creates a beep sound. |
||||||
|
*/ |
||||||
|
beep () { |
||||||
|
const audioCtx = this.audioCtx |
||||||
|
if (!audioCtx) return |
||||||
|
|
||||||
|
// prevent screeching
|
||||||
|
if (this._lastBeep && this._lastBeep > Date.now() - 50) return |
||||||
|
this._lastBeep = Date.now() |
||||||
|
|
||||||
|
if (!this._convolver) { |
||||||
|
this._convolver = audioCtx.createConvolver() |
||||||
|
let impulseLength = audioCtx.sampleRate * 0.8 |
||||||
|
let impulse = audioCtx.createBuffer(2, impulseLength, audioCtx.sampleRate) |
||||||
|
for (let i = 0; i < impulseLength; i++) { |
||||||
|
impulse.getChannelData(0)[i] = (1 - i / impulseLength) ** (7 + Math.random()) |
||||||
|
impulse.getChannelData(1)[i] = (1 - i / impulseLength) ** (7 + Math.random()) |
||||||
|
} |
||||||
|
this._convolver.buffer = impulse |
||||||
|
this._convolver.connect(audioCtx.destination) |
||||||
|
} |
||||||
|
|
||||||
|
// main beep
|
||||||
|
const mainOsc = audioCtx.createOscillator() |
||||||
|
const mainGain = audioCtx.createGain() |
||||||
|
mainOsc.connect(mainGain) |
||||||
|
mainGain.gain.value = 4 |
||||||
|
mainOsc.frequency.value = 750 |
||||||
|
mainOsc.type = 'sine' |
||||||
|
|
||||||
|
// surrogate beep (making it sound like 'oops')
|
||||||
|
const surrOsc = audioCtx.createOscillator() |
||||||
|
const surrGain = audioCtx.createGain() |
||||||
|
surrOsc.connect(surrGain) |
||||||
|
surrGain.gain.value = 2 |
||||||
|
surrOsc.frequency.value = 400 |
||||||
|
surrOsc.type = 'sine' |
||||||
|
|
||||||
|
mainGain.connect(this._convolver) |
||||||
|
surrGain.connect(this._convolver) |
||||||
|
|
||||||
|
let startTime = audioCtx.currentTime |
||||||
|
mainOsc.start() |
||||||
|
mainOsc.stop(startTime + 0.5) |
||||||
|
surrOsc.start(startTime + 0.05) |
||||||
|
surrOsc.stop(startTime + 0.8) |
||||||
|
|
||||||
|
let loop = function () { |
||||||
|
if (audioCtx.currentTime < startTime + 0.8) window.requestAnimationFrame(loop) |
||||||
|
mainGain.gain.value *= 0.8 |
||||||
|
surrGain.gain.value *= 0.8 |
||||||
|
} |
||||||
|
loop() |
||||||
|
} |
||||||
|
|
||||||
|
load (...args) { |
||||||
|
this.parser.load(...args) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,269 @@ |
|||||||
|
const $ = require('../lib/chibi') |
||||||
|
const { qs } = require('../utils') |
||||||
|
const { themes } = require('./themes') |
||||||
|
|
||||||
|
// constants for decoding the update blob
|
||||||
|
const SEQ_REPEAT = 2 |
||||||
|
const SEQ_SET_COLORS = 3 |
||||||
|
const SEQ_SET_ATTRS = 4 |
||||||
|
const SEQ_SET_FG = 5 |
||||||
|
const SEQ_SET_BG = 6 |
||||||
|
|
||||||
|
module.exports = class ScreenParser { |
||||||
|
constructor (screen) { |
||||||
|
this.screen = screen |
||||||
|
|
||||||
|
// true if TermScreen#load was called at least once
|
||||||
|
this.contentLoaded = false |
||||||
|
} |
||||||
|
/** |
||||||
|
* Parses the content of an `S` message and schedules a draw |
||||||
|
* @param {string} str - the message content |
||||||
|
*/ |
||||||
|
loadContent (str) { |
||||||
|
// current index
|
||||||
|
let i = 0 |
||||||
|
let strArray = Array.from ? Array.from(str) : str.split('') |
||||||
|
|
||||||
|
// Uncomment to capture screen content for the demo page
|
||||||
|
// console.log(JSON.stringify(`S${str}`))
|
||||||
|
|
||||||
|
if (!this.contentLoaded) { |
||||||
|
let errmsg = qs('#load-failed') |
||||||
|
if (errmsg) errmsg.parentNode.removeChild(errmsg) |
||||||
|
this.contentLoaded = true |
||||||
|
} |
||||||
|
|
||||||
|
// window size
|
||||||
|
const newHeight = strArray[i++].codePointAt(0) - 1 |
||||||
|
const newWidth = strArray[i++].codePointAt(0) - 1 |
||||||
|
const resized = (this.screen.window.height !== newHeight) || (this.screen.window.width !== newWidth) |
||||||
|
this.screen.window.height = newHeight |
||||||
|
this.screen.window.width = newWidth |
||||||
|
|
||||||
|
// cursor position
|
||||||
|
let [cursorY, cursorX] = [ |
||||||
|
strArray[i++].codePointAt(0) - 1, |
||||||
|
strArray[i++].codePointAt(0) - 1 |
||||||
|
] |
||||||
|
let cursorMoved = (cursorX !== this.screen.cursor.x || cursorY !== this.screen.cursor.y) |
||||||
|
this.screen.cursor.x = cursorX |
||||||
|
this.screen.cursor.y = cursorY |
||||||
|
|
||||||
|
if (cursorMoved) { |
||||||
|
this.screen.renderer.resetCursorBlink() |
||||||
|
this.screen.emit('cursor-moved') |
||||||
|
} |
||||||
|
|
||||||
|
// attributes
|
||||||
|
let attributes = strArray[i++].codePointAt(0) - 1 |
||||||
|
|
||||||
|
this.screen.cursor.visible = !!(attributes & 1) |
||||||
|
this.screen.cursor.hanging = !!(attributes & (1 << 1)) |
||||||
|
|
||||||
|
this.screen.input.setAlts( |
||||||
|
!!(attributes & (1 << 2)), // cursors alt
|
||||||
|
!!(attributes & (1 << 3)), // numpad alt
|
||||||
|
!!(attributes & (1 << 4)), // fn keys alt
|
||||||
|
!!(attributes & (1 << 12)) // crlf mode
|
||||||
|
) |
||||||
|
|
||||||
|
let trackMouseClicks = !!(attributes & (1 << 5)) |
||||||
|
let trackMouseMovement = !!(attributes & (1 << 6)) |
||||||
|
|
||||||
|
// 0 - Block blink 2 - Block steady (1 is unused)
|
||||||
|
// 3 - Underline blink 4 - Underline steady
|
||||||
|
// 5 - I-bar blink 6 - I-bar steady
|
||||||
|
let cursorShape = (attributes >> 9) & 0x07 |
||||||
|
|
||||||
|
// if it's not zero, decrement such that the two most significant bits
|
||||||
|
// are the type and the least significant bit is the blink state
|
||||||
|
if (cursorShape > 0) cursorShape-- |
||||||
|
|
||||||
|
let cursorStyle = cursorShape >> 1 |
||||||
|
let cursorBlinking = !(cursorShape & 1) |
||||||
|
|
||||||
|
if (cursorStyle === 0) this.screen.cursor.style = 'block' |
||||||
|
else if (cursorStyle === 1) this.screen.cursor.style = 'line' |
||||||
|
else if (cursorStyle === 2) this.screen.cursor.style = 'bar' |
||||||
|
|
||||||
|
if (this.screen.cursor.blinking !== cursorBlinking) { |
||||||
|
this.screen.cursor.blinking = cursorBlinking |
||||||
|
this.screen.renderer.resetCursorBlink() |
||||||
|
} |
||||||
|
|
||||||
|
this.screen.input.setMouseMode(trackMouseClicks, trackMouseMovement) |
||||||
|
this.screen.selection.selectable = !trackMouseClicks && !trackMouseMovement |
||||||
|
$(this.screen.canvas).toggleClass('selectable', this.screen.selection.selectable) |
||||||
|
this.screen.mouseMode = { |
||||||
|
clicks: trackMouseClicks, |
||||||
|
movement: trackMouseMovement |
||||||
|
} |
||||||
|
|
||||||
|
let showButtons = !!(attributes & (1 << 7)) |
||||||
|
let showConfigLinks = !!(attributes & (1 << 8)) |
||||||
|
|
||||||
|
$('.x-term-conf-btn').toggleClass('hidden', !showConfigLinks) |
||||||
|
$('#action-buttons').toggleClass('hidden', !showButtons) |
||||||
|
|
||||||
|
this.screen.bracketedPaste = !!(attributes & (1 << 13)) |
||||||
|
this.screen.reverseVideo = !!(attributes & (1 << 14)) |
||||||
|
|
||||||
|
// content
|
||||||
|
let fg = 7 |
||||||
|
let bg = 0 |
||||||
|
let attrs = 0 |
||||||
|
let cell = 0 // cell index
|
||||||
|
let lastChar = ' ' |
||||||
|
let screenLength = this.screen.window.width * this.screen.window.height |
||||||
|
|
||||||
|
if (resized) { |
||||||
|
this.screen.updateSize() |
||||||
|
this.screen.blinkingCellCount = 0 |
||||||
|
this.screen.screen = new Array(screenLength).fill(' ') |
||||||
|
this.screen.screenFG = new Array(screenLength).fill(' ') |
||||||
|
this.screen.screenBG = new Array(screenLength).fill(' ') |
||||||
|
this.screen.screenAttrs = new Array(screenLength).fill(0) |
||||||
|
} |
||||||
|
|
||||||
|
const MASK_LINE_ATTR = 0xC8 |
||||||
|
const MASK_BLINK = 1 << 4 |
||||||
|
|
||||||
|
let setCellContent = () => { |
||||||
|
// Remove blink attribute if it wouldn't have any effect
|
||||||
|
let myAttrs = attrs |
||||||
|
let hasFG = attrs & (1 << 8) |
||||||
|
let hasBG = attrs & (1 << 9) |
||||||
|
if ((myAttrs & MASK_BLINK) !== 0 && |
||||||
|
((lastChar === ' ' && ((myAttrs & MASK_LINE_ATTR) === 0)) || // no line styles
|
||||||
|
(fg === bg && hasFG && hasBG) // invisible text
|
||||||
|
) |
||||||
|
) { |
||||||
|
myAttrs ^= MASK_BLINK |
||||||
|
} |
||||||
|
// update blinking cells counter if blink state changed
|
||||||
|
if ((this.screen.screenAttrs[cell] & MASK_BLINK) !== (myAttrs & MASK_BLINK)) { |
||||||
|
if (myAttrs & MASK_BLINK) this.screen.blinkingCellCount++ |
||||||
|
else this.screen.blinkingCellCount-- |
||||||
|
} |
||||||
|
|
||||||
|
this.screen.screen[cell] = lastChar |
||||||
|
this.screen.screenFG[cell] = fg |
||||||
|
this.screen.screenBG[cell] = bg |
||||||
|
this.screen.screenAttrs[cell] = myAttrs |
||||||
|
} |
||||||
|
|
||||||
|
while (i < strArray.length && cell < screenLength) { |
||||||
|
let character = strArray[i++] |
||||||
|
let charCode = character.codePointAt(0) |
||||||
|
|
||||||
|
let data |
||||||
|
switch (charCode) { |
||||||
|
case SEQ_REPEAT: |
||||||
|
let count = strArray[i++].codePointAt(0) - 1 |
||||||
|
for (let j = 0; j < count; j++) { |
||||||
|
setCellContent() |
||||||
|
if (++cell > screenLength) break |
||||||
|
} |
||||||
|
break |
||||||
|
|
||||||
|
case SEQ_SET_COLORS: |
||||||
|
data = strArray[i++].codePointAt(0) - 1 |
||||||
|
fg = data & 0xFF |
||||||
|
bg = (data >> 8) & 0xFF |
||||||
|
break |
||||||
|
|
||||||
|
case SEQ_SET_ATTRS: |
||||||
|
data = strArray[i++].codePointAt(0) - 1 |
||||||
|
attrs = data & 0xFFFF |
||||||
|
break |
||||||
|
|
||||||
|
case SEQ_SET_FG: |
||||||
|
data = strArray[i++].codePointAt(0) - 1 |
||||||
|
fg = data & 0xFF |
||||||
|
break |
||||||
|
|
||||||
|
case SEQ_SET_BG: |
||||||
|
data = strArray[i++].codePointAt(0) - 1 |
||||||
|
bg = data & 0xFF |
||||||
|
break |
||||||
|
|
||||||
|
default: |
||||||
|
if (charCode < 32) character = '\ufffd' |
||||||
|
lastChar = character |
||||||
|
setCellContent() |
||||||
|
cell++ |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (this.screen.window.debug) console.log(`Blinky cells: ${this.screen.blinkingCellCount}`) |
||||||
|
|
||||||
|
this.screen.renderer.scheduleDraw('load', 16) |
||||||
|
this.screen.emit('load') |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parses the content of a `T` message and updates the screen title and button |
||||||
|
* labels. |
||||||
|
* @param {string} str - the message content |
||||||
|
*/ |
||||||
|
loadLabels (str) { |
||||||
|
let pieces = str.split('\x01') |
||||||
|
let screenTitle = pieces[0] |
||||||
|
qs('#screen-title').textContent = screenTitle |
||||||
|
if (screenTitle.length === 0) screenTitle = 'Terminal' |
||||||
|
qs('title').textContent = `${screenTitle} :: ESPTerm` |
||||||
|
$('#action-buttons button').forEach((button, i) => { |
||||||
|
let label = pieces[i + 1].trim() |
||||||
|
// if empty string, use the "dim" effect and put nbsp instead to
|
||||||
|
// stretch the button vertically
|
||||||
|
button.innerHTML = label ? $.htmlEscape(label) : ' ' |
||||||
|
button.style.opacity = label ? 1 : 0.2 |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Loads a message from the server, and optionally a theme. |
||||||
|
* @param {string} str - the message |
||||||
|
* @param {object} [opts] - options |
||||||
|
* @param {number} [opts.theme] - theme |
||||||
|
* @param {number} [opts.defaultFg] - default foreground |
||||||
|
* @param {number} [opts.defaultBg] - default background |
||||||
|
*/ |
||||||
|
load (str, opts = null) { |
||||||
|
const content = str.substr(1) |
||||||
|
|
||||||
|
if (opts) { |
||||||
|
if (typeof opts.defaultFg !== 'undefined' && typeof opts.defaultBg !== 'undefined') { |
||||||
|
this.screen.renderer.setDefaultColors(opts.defaultFg, opts.defaultBg) |
||||||
|
} |
||||||
|
|
||||||
|
if (typeof opts.theme !== 'undefined') { |
||||||
|
if (opts.theme >= 0 && opts.theme < themes.length) { |
||||||
|
this.screen.renderer.palette = themes[opts.theme] |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
switch (str[0]) { |
||||||
|
case 'S': |
||||||
|
this.loadContent(content) |
||||||
|
break |
||||||
|
|
||||||
|
case 'T': |
||||||
|
this.loadLabels(content) |
||||||
|
break |
||||||
|
|
||||||
|
case 'B': |
||||||
|
this.screen.beep() |
||||||
|
break |
||||||
|
|
||||||
|
case 'G': |
||||||
|
this.screen.showNotification(content) |
||||||
|
break |
||||||
|
|
||||||
|
default: |
||||||
|
console.warn(`Bad data message type; ignoring.\n${JSON.stringify(str)}`) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,690 @@ |
|||||||
|
const { themes, buildColorTable, SELECTION_FG, SELECTION_BG } = require('./themes') |
||||||
|
|
||||||
|
// Some non-bold Fraktur symbols are outside the contiguous block
|
||||||
|
const frakturExceptions = { |
||||||
|
'C': '\u212d', |
||||||
|
'H': '\u210c', |
||||||
|
'I': '\u2111', |
||||||
|
'R': '\u211c', |
||||||
|
'Z': '\u2128' |
||||||
|
} |
||||||
|
|
||||||
|
module.exports = class ScreenRenderer { |
||||||
|
constructor (screen) { |
||||||
|
this.screen = screen |
||||||
|
this.ctx = screen.ctx |
||||||
|
|
||||||
|
this._palette = null // colors 0-15
|
||||||
|
this.defaultBgNum = 0 |
||||||
|
this.defaultFgNum = 7 |
||||||
|
|
||||||
|
// 256color lookup table
|
||||||
|
// should not be used to look up 0-15 (will return transparent)
|
||||||
|
this.colorTable256 = buildColorTable() |
||||||
|
|
||||||
|
this.resetDrawn() |
||||||
|
|
||||||
|
this.blinkStyleOn = false |
||||||
|
this.blinkInterval = null |
||||||
|
this.cursorBlinkOn = false |
||||||
|
this.cursorBlinkInterval = null |
||||||
|
|
||||||
|
// start blink timers
|
||||||
|
this.resetBlink() |
||||||
|
this.resetCursorBlink() |
||||||
|
} |
||||||
|
|
||||||
|
resetDrawn () { |
||||||
|
// used to determine if a cell should be redrawn; storing the current state
|
||||||
|
// as it is on screen
|
||||||
|
this.drawnScreen = [] |
||||||
|
this.drawnScreenFG = [] |
||||||
|
this.drawnScreenBG = [] |
||||||
|
this.drawnScreenAttrs = [] |
||||||
|
this.drawnCursor = [-1, -1, ''] |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* The color palette. Should define 16 colors in an array. |
||||||
|
* @type {string[]} |
||||||
|
*/ |
||||||
|
get palette () { |
||||||
|
return this._palette || themes[0] |
||||||
|
} |
||||||
|
|
||||||
|
/** @type {string[]} */ |
||||||
|
set palette (palette) { |
||||||
|
if (this._palette !== palette) { |
||||||
|
this._palette = palette |
||||||
|
this.resetDrawn() |
||||||
|
this.scheduleDraw('palette') |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
setDefaultColors (fg, bg) { |
||||||
|
this.defaultFgNum = fg |
||||||
|
this.defaultBgNum = bg |
||||||
|
this.resetDrawn() |
||||||
|
this.scheduleDraw('defaultColors') |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Schedule a draw in the next millisecond |
||||||
|
* @param {string} why - the reason why the draw occured (for debugging) |
||||||
|
* @param {number} [aggregateTime] - time to wait for more scheduleDraw calls |
||||||
|
* to occur. 1 ms by default. |
||||||
|
*/ |
||||||
|
scheduleDraw (why, aggregateTime = 1) { |
||||||
|
clearTimeout(this._scheduledDraw) |
||||||
|
this._scheduledDraw = setTimeout(() => this.draw(why), aggregateTime) |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns the specified color. If `i` is in the palette, it will return the |
||||||
|
* palette color. If `i` is between 16 and 255, it will return the 256color |
||||||
|
* value. If `i` is larger than 255, it will return an RGB color value. If `i` |
||||||
|
* is -1 (foreground) or -2 (background), it will return the selection colors. |
||||||
|
* @param {number} i - the color |
||||||
|
* @returns {string} the CSS color |
||||||
|
*/ |
||||||
|
getColor (i) { |
||||||
|
// return palette color if it exists
|
||||||
|
if (i < 16 && i in this.palette) return this.palette[i] |
||||||
|
|
||||||
|
// -1 for selection foreground, -2 for selection background
|
||||||
|
if (i === -1) return SELECTION_FG |
||||||
|
if (i === -2) return SELECTION_BG |
||||||
|
|
||||||
|
// 256 color
|
||||||
|
if (i > 15 && i < 256) return this.colorTable256[i] |
||||||
|
|
||||||
|
// true color, encoded as (hex) + 256 (such that #000 == 256)
|
||||||
|
if (i > 255) { |
||||||
|
i -= 256 |
||||||
|
let red = (i >> 16) & 0xFF |
||||||
|
let green = (i >> 8) & 0xFF |
||||||
|
let blue = i & 0xFF |
||||||
|
return `rgb(${red}, ${green}, ${blue})` |
||||||
|
} |
||||||
|
|
||||||
|
// return error color
|
||||||
|
return (Date.now() / 1000) % 2 === 0 ? '#f0f' : '#0f0' |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Resets the cursor blink to on and restarts the timer |
||||||
|
*/ |
||||||
|
resetCursorBlink () { |
||||||
|
this.cursorBlinkOn = true |
||||||
|
clearInterval(this.cursorBlinkInterval) |
||||||
|
this.cursorBlinkInterval = setInterval(() => { |
||||||
|
this.cursorBlinkOn = this.screen.cursor.blinking |
||||||
|
? !this.cursorBlinkOn |
||||||
|
: true |
||||||
|
if (this.screen.cursor.blinking) this.scheduleDraw('cursor-blink') |
||||||
|
}, 500) |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Resets the blink style to on and restarts the timer |
||||||
|
*/ |
||||||
|
resetBlink () { |
||||||
|
this.blinkStyleOn = true |
||||||
|
clearInterval(this.blinkInterval) |
||||||
|
let intervals = 0 |
||||||
|
this.blinkInterval = setInterval(() => { |
||||||
|
if (this.screen.blinkingCellCount <= 0) return |
||||||
|
|
||||||
|
intervals++ |
||||||
|
if (intervals >= 4 && this.blinkStyleOn) { |
||||||
|
this.blinkStyleOn = false |
||||||
|
intervals = 0 |
||||||
|
this.scheduleDraw('blink-style') |
||||||
|
} else if (intervals >= 1 && !this.blinkStyleOn) { |
||||||
|
this.blinkStyleOn = true |
||||||
|
intervals = 0 |
||||||
|
this.scheduleDraw('blink-style') |
||||||
|
} |
||||||
|
}, 200) |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Draws a cell's background with the given parameters. |
||||||
|
* @param {Object} options |
||||||
|
* @param {number} options.x - x in cells |
||||||
|
* @param {number} options.y - y in cells |
||||||
|
* @param {number} options.cellWidth - cell width in pixels |
||||||
|
* @param {number} options.cellHeight - cell height in pixels |
||||||
|
* @param {number} options.bg - the background color |
||||||
|
*/ |
||||||
|
drawBackground ({ x, y, cellWidth, cellHeight, bg }) { |
||||||
|
const ctx = this.ctx |
||||||
|
ctx.fillStyle = this.getColor(bg) |
||||||
|
ctx.clearRect(x * cellWidth, y * cellHeight, cellWidth, cellHeight) |
||||||
|
ctx.fillRect(x * cellWidth, y * cellHeight, cellWidth, cellHeight) |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Draws a cell's character with the given parameters. Won't do anything if |
||||||
|
* text is an empty string. |
||||||
|
* @param {Object} options |
||||||
|
* @param {number} options.x - x in cells |
||||||
|
* @param {number} options.y - y in cells |
||||||
|
* @param {Object} options.charSize - the character size, an object with |
||||||
|
* `width` and `height` in pixels |
||||||
|
* @param {number} options.cellWidth - cell width in pixels |
||||||
|
* @param {number} options.cellHeight - cell height in pixels |
||||||
|
* @param {string} options.text - the cell content |
||||||
|
* @param {number} options.fg - the foreground color |
||||||
|
* @param {number} options.attrs - the cell's attributes |
||||||
|
*/ |
||||||
|
drawCharacter ({ x, y, charSize, cellWidth, cellHeight, text, fg, attrs }) { |
||||||
|
if (!text) return |
||||||
|
|
||||||
|
const ctx = this.ctx |
||||||
|
|
||||||
|
let underline = false |
||||||
|
let strike = false |
||||||
|
let overline = false |
||||||
|
if (attrs & (1 << 1)) ctx.globalAlpha = 0.5 |
||||||
|
if (attrs & (1 << 3)) underline = true |
||||||
|
if (attrs & (1 << 5)) text = ScreenRenderer.alphaToFraktur(text) |
||||||
|
if (attrs & (1 << 6)) strike = true |
||||||
|
if (attrs & (1 << 7)) overline = true |
||||||
|
|
||||||
|
ctx.fillStyle = this.getColor(fg) |
||||||
|
|
||||||
|
let codePoint = text.codePointAt(0) |
||||||
|
if (codePoint >= 0x2580 && codePoint <= 0x259F) { |
||||||
|
// block elements
|
||||||
|
ctx.beginPath() |
||||||
|
const left = x * cellWidth |
||||||
|
const top = y * cellHeight |
||||||
|
const cw = cellWidth |
||||||
|
const ch = cellHeight |
||||||
|
const c2w = cellWidth / 2 |
||||||
|
const c2h = cellHeight / 2 |
||||||
|
|
||||||
|
// http://www.fileformat.info/info/unicode/block/block_elements/utf8test.htm
|
||||||
|
// 0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09 0x0A 0x0B 0x0C 0x0D 0x0E 0x0F
|
||||||
|
// 0x2580 ▀ ▁ ▂ ▃ ▄ ▅ ▆ ▇ █ ▉ ▊ ▋ ▌ ▍ ▎ ▏
|
||||||
|
// 0x2590 ▐ ░ ▒ ▓ ▔ ▕ ▖ ▗ ▘ ▙ ▚ ▛ ▜ ▝ ▞ ▟
|
||||||
|
|
||||||
|
if (codePoint === 0x2580) { |
||||||
|
// upper half block >▀<
|
||||||
|
ctx.rect(left, top, cw, c2h) |
||||||
|
} else if (codePoint <= 0x2588) { |
||||||
|
// lower n eighth block (increasing) >▁< to >█<
|
||||||
|
let offset = (1 - (codePoint - 0x2580) / 8) * ch |
||||||
|
ctx.rect(left, top + offset, cw, ch - offset) |
||||||
|
} else if (codePoint <= 0x258F) { |
||||||
|
// left n eighth block (decreasing) >▉< to >▏<
|
||||||
|
let offset = (codePoint - 0x2588) / 8 * cw |
||||||
|
ctx.rect(left, top, cw - offset, ch) |
||||||
|
} else if (codePoint === 0x2590) { |
||||||
|
// right half block >▐<
|
||||||
|
ctx.rect(left + c2w, top, c2w, ch) |
||||||
|
} else if (codePoint <= 0x2593) { |
||||||
|
// shading >░< >▒< >▓<
|
||||||
|
|
||||||
|
// dot spacing by dividing cell size by a constant. This could be
|
||||||
|
// reworked to always return a whole number, but that would require
|
||||||
|
// prime factorization, and doing that without a loop would let you
|
||||||
|
// take over the world, which is not within the scope of this project.
|
||||||
|
let dotSpacingX, dotSpacingY, dotSize |
||||||
|
if (codePoint === 0x2591) { |
||||||
|
dotSpacingX = cw / 4 |
||||||
|
dotSpacingY = ch / 10 |
||||||
|
dotSize = 1 |
||||||
|
} else if (codePoint === 0x2592) { |
||||||
|
dotSpacingX = cw / 6 |
||||||
|
dotSpacingY = cw / 10 |
||||||
|
dotSize = 1 |
||||||
|
} else if (codePoint === 0x2593) { |
||||||
|
dotSpacingX = cw / 4 |
||||||
|
dotSpacingY = cw / 7 |
||||||
|
dotSize = 2 |
||||||
|
} |
||||||
|
|
||||||
|
let alignRight = false |
||||||
|
for (let dy = 0; dy < ch; dy += dotSpacingY) { |
||||||
|
for (let dx = 0; dx < cw; dx += dotSpacingX) { |
||||||
|
// prevent overflow
|
||||||
|
let dotSizeY = Math.min(dotSize, ch - dy) |
||||||
|
ctx.rect(x * cw + (alignRight ? cw - dx - dotSize : dx), y * ch + dy, dotSize, dotSizeY) |
||||||
|
} |
||||||
|
alignRight = !alignRight |
||||||
|
} |
||||||
|
} else if (codePoint === 0x2594) { |
||||||
|
// upper one eighth block >▔<
|
||||||
|
ctx.rect(x * cw, y * ch, cw, ch / 8) |
||||||
|
} else if (codePoint === 0x2595) { |
||||||
|
// right one eighth block >▕<
|
||||||
|
ctx.rect((x + 7 / 8) * cw, y * ch, cw / 8, ch) |
||||||
|
} else if (codePoint === 0x2596) { |
||||||
|
// left bottom quadrant >▖<
|
||||||
|
ctx.rect(left, top + c2h, c2w, c2h) |
||||||
|
} else if (codePoint === 0x2597) { |
||||||
|
// right bottom quadrant >▗<
|
||||||
|
ctx.rect(left + c2w, top + c2h, c2w, c2h) |
||||||
|
} else if (codePoint === 0x2598) { |
||||||
|
// left top quadrant >▘<
|
||||||
|
ctx.rect(left, top, c2w, c2h) |
||||||
|
} else if (codePoint === 0x2599) { |
||||||
|
// left chair >▙<
|
||||||
|
ctx.rect(left, top, c2w, ch) |
||||||
|
ctx.rect(left + c2w, top + c2h, c2w, c2h) |
||||||
|
} else if (codePoint === 0x259A) { |
||||||
|
// quadrants lt rb >▚<
|
||||||
|
ctx.rect(left, top, c2w, c2h) |
||||||
|
ctx.rect(left + c2w, top + c2h, c2w, c2h) |
||||||
|
} else if (codePoint === 0x259B) { |
||||||
|
// left chair upside down >▛<
|
||||||
|
ctx.rect(left, top, c2w, ch) |
||||||
|
ctx.rect(left + c2w, top, c2w, c2h) |
||||||
|
} else if (codePoint === 0x259C) { |
||||||
|
// right chair upside down >▜<
|
||||||
|
ctx.rect(left, top, cw, c2h) |
||||||
|
ctx.rect(left + c2w, top + c2h, c2w, c2h) |
||||||
|
} else if (codePoint === 0x259D) { |
||||||
|
// right top quadrant >▝<
|
||||||
|
ctx.rect(left + c2w, top, c2w, c2h) |
||||||
|
} else if (codePoint === 0x259E) { |
||||||
|
// quadrants lb rt >▞<
|
||||||
|
ctx.rect(left, top + c2h, c2w, c2h) |
||||||
|
ctx.rect(left + c2w, top, c2w, c2h) |
||||||
|
} else if (codePoint === 0x259F) { |
||||||
|
// right chair upside down >▟<
|
||||||
|
ctx.rect(left, top + c2h, c2w, c2h) |
||||||
|
ctx.rect(left + c2w, top, c2w, ch) |
||||||
|
} |
||||||
|
|
||||||
|
ctx.fill() |
||||||
|
} else { |
||||||
|
// Draw other characters using the text renderer
|
||||||
|
ctx.fillText(text, (x + 0.5) * cellWidth, (y + 0.5) * cellHeight) |
||||||
|
} |
||||||
|
|
||||||
|
// -- line drawing - a reference for a possible future rect/line implementation ---
|
||||||
|
// http://www.fileformat.info/info/unicode/block/box_drawing/utf8test.htm
|
||||||
|
// 0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09 0x0A 0x0B 0x0C 0x0D 0x0E 0x0F
|
||||||
|
// 0x2500 ─ ━ │ ┃ ┄ ┅ ┆ ┇ ┈ ┉ ┊ ┋ ┌ ┍ ┎ ┏
|
||||||
|
// 0x2510 ┐ ┑ ┒ ┓ └ ┕ ┖ ┗ ┘ ┙ ┚ ┛ ├ ┝ ┞ ┟
|
||||||
|
// 0x2520 ┠ ┡ ┢ ┣ ┤ ┥ ┦ ┧ ┨ ┩ ┪ ┫ ┬ ┭ ┮ ┯
|
||||||
|
// 0x2530 ┰ ┱ ┲ ┳ ┴ ┵ ┶ ┷ ┸ ┹ ┺ ┻ ┼ ┽ ┾ ┿
|
||||||
|
// 0x2540 ╀ ╁ ╂ ╃ ╄ ╅ ╆ ╇ ╈ ╉ ╊ ╋ ╌ ╍ ╎ ╏
|
||||||
|
// 0x2550 ═ ║ ╒ ╓ ╔ ╕ ╖ ╗ ╘ ╙ ╚ ╛ ╜ ╝ ╞ ╟
|
||||||
|
// 0x2560 ╠ ╡ ╢ ╣ ╤ ╥ ╦ ╧ ╨ ╩ ╪ ╫ ╬ ╭ ╮ ╯
|
||||||
|
// 0x2570 ╰ ╱ ╲ ╳ ╴ ╵ ╶ ╷ ╸ ╹ ╺ ╻ ╼ ╽ ╾ ╿
|
||||||
|
|
||||||
|
if (underline || strike || overline) { |
||||||
|
ctx.strokeStyle = this.getColor(fg) |
||||||
|
ctx.lineWidth = 1 |
||||||
|
ctx.lineCap = 'round' |
||||||
|
ctx.beginPath() |
||||||
|
|
||||||
|
if (underline) { |
||||||
|
let lineY = Math.round(y * cellHeight + charSize.height) + 0.5 |
||||||
|
ctx.moveTo(x * cellWidth, lineY) |
||||||
|
ctx.lineTo((x + 1) * cellWidth, lineY) |
||||||
|
} |
||||||
|
|
||||||
|
if (strike) { |
||||||
|
let lineY = Math.round((y + 0.5) * cellHeight) + 0.5 |
||||||
|
ctx.moveTo(x * cellWidth, lineY) |
||||||
|
ctx.lineTo((x + 1) * cellWidth, lineY) |
||||||
|
} |
||||||
|
|
||||||
|
if (overline) { |
||||||
|
let lineY = Math.round(y * cellHeight) + 0.5 |
||||||
|
ctx.moveTo(x * cellWidth, lineY) |
||||||
|
ctx.lineTo((x + 1) * cellWidth, lineY) |
||||||
|
} |
||||||
|
|
||||||
|
ctx.stroke() |
||||||
|
} |
||||||
|
|
||||||
|
ctx.globalAlpha = 1 |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns all adjacent cell indices given a radius. |
||||||
|
* @param {number} cell - the center cell index |
||||||
|
* @param {number} [radius] - the radius. 1 by default |
||||||
|
* @returns {number[]} an array of cell indices |
||||||
|
*/ |
||||||
|
getAdjacentCells (cell, radius = 1) { |
||||||
|
const { width, height } = this.screen.window |
||||||
|
const screenLength = width * height |
||||||
|
|
||||||
|
let cells = [] |
||||||
|
|
||||||
|
for (let x = -radius; x <= radius; x++) { |
||||||
|
for (let y = -radius; y <= radius; y++) { |
||||||
|
if (x === 0 && y === 0) continue |
||||||
|
cells.push(cell + x + y * width) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return cells.filter(cell => cell >= 0 && cell < screenLength) |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Updates the screen. |
||||||
|
* @param {string} why - the draw reason (for debugging) |
||||||
|
*/ |
||||||
|
draw (why) { |
||||||
|
const ctx = this.ctx |
||||||
|
const { |
||||||
|
width, |
||||||
|
height, |
||||||
|
devicePixelRatio, |
||||||
|
statusScreen |
||||||
|
} = this.screen.window |
||||||
|
|
||||||
|
if (statusScreen) { |
||||||
|
// draw status screen instead
|
||||||
|
this.drawStatus(statusScreen) |
||||||
|
this.startDrawLoop() |
||||||
|
return |
||||||
|
} else this.stopDrawLoop() |
||||||
|
|
||||||
|
const charSize = this.screen.getCharSize() |
||||||
|
const { width: cellWidth, height: cellHeight } = this.screen.getCellSize() |
||||||
|
const screenLength = width * height |
||||||
|
|
||||||
|
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0) |
||||||
|
|
||||||
|
if (this.screen.window.debug && this.screen._debug) this.screen._debug.drawStart(why) |
||||||
|
|
||||||
|
ctx.font = this.screen.getFont() |
||||||
|
ctx.textAlign = 'center' |
||||||
|
ctx.textBaseline = 'middle' |
||||||
|
|
||||||
|
// bits in the attr value that affect the font
|
||||||
|
const FONT_MASK = 0b101 |
||||||
|
|
||||||
|
// Map of (attrs & FONT_MASK) -> Array of cell indices
|
||||||
|
let fontGroups = new Map() |
||||||
|
|
||||||
|
// Map of (cell index) -> boolean, whether or not a cell has updated
|
||||||
|
let updateMap = new Map() |
||||||
|
|
||||||
|
for (let cell = 0; cell < screenLength; cell++) { |
||||||
|
let x = cell % width |
||||||
|
let y = Math.floor(cell / width) |
||||||
|
let isCursor = !this.screen.cursor.hanging && |
||||||
|
this.screen.cursor.x === x && |
||||||
|
this.screen.cursor.y === y && |
||||||
|
this.screen.cursor.visible && |
||||||
|
this.cursorBlinkOn |
||||||
|
|
||||||
|
let wasCursor = x === this.drawnCursor[0] && y === this.drawnCursor[1] |
||||||
|
|
||||||
|
let inSelection = this.screen.isInSelection(x, y) |
||||||
|
|
||||||
|
let text = this.screen.screen[cell] |
||||||
|
let fg = this.screen.screenFG[cell] | 0 |
||||||
|
let bg = this.screen.screenBG[cell] | 0 |
||||||
|
let attrs = this.screen.screenAttrs[cell] | 0 |
||||||
|
|
||||||
|
if (!(attrs & (1 << 8))) fg = this.defaultFgNum |
||||||
|
if (!(attrs & (1 << 9))) bg = this.defaultBgNum |
||||||
|
|
||||||
|
if (attrs & (1 << 10)) [fg, bg] = [bg, fg] // swap - reversed character colors
|
||||||
|
if (this.screen.reverseVideo) [fg, bg] = [bg, fg] // swap - reversed all screen
|
||||||
|
|
||||||
|
if (attrs & (1 << 4) && !this.blinkStyleOn) { |
||||||
|
// blinking is enabled and blink style is off
|
||||||
|
// set text to nothing so drawCharacter doesn't draw anything
|
||||||
|
text = '' |
||||||
|
} |
||||||
|
|
||||||
|
if (inSelection) { |
||||||
|
fg = -1 |
||||||
|
bg = -2 |
||||||
|
} |
||||||
|
|
||||||
|
let didUpdate = text !== this.drawnScreen[cell] || // text updated
|
||||||
|
fg !== this.drawnScreenFG[cell] || // foreground updated, and this cell has text
|
||||||
|
bg !== this.drawnScreenBG[cell] || // background updated
|
||||||
|
attrs !== this.drawnScreenAttrs[cell] || // attributes updated
|
||||||
|
isCursor !== wasCursor || // cursor blink/position updated
|
||||||
|
(isCursor && this.screen.cursor.style !== this.drawnCursor[2]) // cursor style updated
|
||||||
|
|
||||||
|
let font = attrs & FONT_MASK |
||||||
|
if (!fontGroups.has(font)) fontGroups.set(font, []) |
||||||
|
|
||||||
|
fontGroups.get(font).push({ cell, x, y, text, fg, bg, attrs, isCursor, inSelection }) |
||||||
|
updateMap.set(cell, didUpdate) |
||||||
|
} |
||||||
|
|
||||||
|
// Map of (cell index) -> boolean, whether or not a cell should be redrawn
|
||||||
|
const redrawMap = new Map() |
||||||
|
|
||||||
|
let isTextWide = text => |
||||||
|
text !== ' ' && ctx.measureText(text).width >= (cellWidth + 0.05) |
||||||
|
|
||||||
|
// decide for each cell if it should be redrawn
|
||||||
|
let updateRedrawMapAt = cell => { |
||||||
|
let shouldUpdate = updateMap.get(cell) || redrawMap.get(cell) || false |
||||||
|
|
||||||
|
// TODO: fonts (necessary?)
|
||||||
|
let text = this.screen.screen[cell] |
||||||
|
let isWideCell = isTextWide(text) |
||||||
|
let checkRadius = isWideCell ? 2 : 1 |
||||||
|
|
||||||
|
if (!shouldUpdate) { |
||||||
|
// check adjacent cells
|
||||||
|
let adjacentDidUpdate = false |
||||||
|
|
||||||
|
for (let adjacentCell of this.getAdjacentCells(cell, checkRadius)) { |
||||||
|
// update this cell if:
|
||||||
|
// - the adjacent cell updated (For now, this'll always be true because characters can be slightly larger than they say they are)
|
||||||
|
// - the adjacent cell updated and this cell or the adjacent cell is wide
|
||||||
|
if (updateMap.get(adjacentCell) && (this.screen.window.graphics < 2 || isWideCell || isTextWide(this.screen.screen[adjacentCell]))) { |
||||||
|
adjacentDidUpdate = true |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (adjacentDidUpdate) shouldUpdate = true |
||||||
|
} |
||||||
|
|
||||||
|
redrawMap.set(cell, shouldUpdate) |
||||||
|
} |
||||||
|
|
||||||
|
for (let cell of updateMap.keys()) updateRedrawMapAt(cell) |
||||||
|
|
||||||
|
// mask to redrawing regions only
|
||||||
|
if (this.screen.window.graphics >= 1) { |
||||||
|
let debug = this.screen.window.debug && this.screen._debug |
||||||
|
ctx.save() |
||||||
|
ctx.beginPath() |
||||||
|
for (let y = 0; y < height; y++) { |
||||||
|
let regionStart = null |
||||||
|
for (let x = 0; x < width; x++) { |
||||||
|
let cell = y * width + x |
||||||
|
let redrawing = redrawMap.get(cell) |
||||||
|
if (redrawing && regionStart === null) regionStart = x |
||||||
|
if (!redrawing && regionStart !== null) { |
||||||
|
ctx.rect(regionStart * cellWidth, y * cellHeight, (x - regionStart) * cellWidth, cellHeight) |
||||||
|
if (debug) this.screen._debug.clipRect(regionStart * cellWidth, y * cellHeight, (x - regionStart) * cellWidth, cellHeight) |
||||||
|
regionStart = null |
||||||
|
} |
||||||
|
} |
||||||
|
if (regionStart !== null) { |
||||||
|
ctx.rect(regionStart * cellWidth, y * cellHeight, (width - regionStart) * cellWidth, cellHeight) |
||||||
|
if (debug) this.screen._debug.clipRect(regionStart * cellWidth, y * cellHeight, (width - regionStart) * cellWidth, cellHeight) |
||||||
|
} |
||||||
|
} |
||||||
|
ctx.clip() |
||||||
|
} |
||||||
|
|
||||||
|
// pass 1: backgrounds
|
||||||
|
for (let font of fontGroups.keys()) { |
||||||
|
for (let data of fontGroups.get(font)) { |
||||||
|
let { cell, x, y, text, bg } = data |
||||||
|
|
||||||
|
if (redrawMap.get(cell)) { |
||||||
|
this.drawBackground({ x, y, cellWidth, cellHeight, bg }) |
||||||
|
|
||||||
|
if (this.screen.window.debug && this.screen._debug) { |
||||||
|
// set cell flags
|
||||||
|
let flags = (+redrawMap.get(cell)) |
||||||
|
flags |= (+updateMap.get(cell)) << 1 |
||||||
|
flags |= (+isTextWide(text)) << 2 |
||||||
|
this.screen._debug.setCell(cell, flags) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// reset drawn cursor
|
||||||
|
this.drawnCursor = [-1, -1, -1] |
||||||
|
|
||||||
|
// pass 2: characters
|
||||||
|
for (let font of fontGroups.keys()) { |
||||||
|
// set font once because in Firefox, this is a really slow action for some
|
||||||
|
// reason
|
||||||
|
let modifiers = {} |
||||||
|
if (font & 1) modifiers.weight = 'bold' |
||||||
|
if (font & 1 << 2) modifiers.style = 'italic' |
||||||
|
ctx.font = this.screen.getFont(modifiers) |
||||||
|
|
||||||
|
for (let data of fontGroups.get(font)) { |
||||||
|
let { cell, x, y, text, fg, bg, attrs, isCursor, inSelection } = data |
||||||
|
|
||||||
|
if (redrawMap.get(cell)) { |
||||||
|
this.drawCharacter({ |
||||||
|
x, y, charSize, cellWidth, cellHeight, text, fg, attrs |
||||||
|
}) |
||||||
|
|
||||||
|
this.drawnScreen[cell] = text |
||||||
|
this.drawnScreenFG[cell] = fg |
||||||
|
this.drawnScreenBG[cell] = bg |
||||||
|
this.drawnScreenAttrs[cell] = attrs |
||||||
|
|
||||||
|
if (isCursor) this.drawnCursor = [x, y, this.screen.cursor.style] |
||||||
|
|
||||||
|
if (isCursor && !inSelection) { |
||||||
|
ctx.save() |
||||||
|
ctx.beginPath() |
||||||
|
if (this.screen.cursor.style === 'block') { |
||||||
|
// block
|
||||||
|
ctx.rect(x * cellWidth, y * cellHeight, cellWidth, cellHeight) |
||||||
|
} else if (this.screen.cursor.style === 'bar') { |
||||||
|
// vertical bar
|
||||||
|
let barWidth = 2 |
||||||
|
ctx.rect(x * cellWidth, y * cellHeight, barWidth, cellHeight) |
||||||
|
} else if (this.screen.cursor.style === 'line') { |
||||||
|
// underline
|
||||||
|
let lineHeight = 2 |
||||||
|
ctx.rect(x * cellWidth, y * cellHeight + charSize.height, cellWidth, lineHeight) |
||||||
|
} |
||||||
|
ctx.clip() |
||||||
|
|
||||||
|
// swap foreground/background
|
||||||
|
;[fg, bg] = [bg, fg] |
||||||
|
|
||||||
|
// HACK: ensure cursor is visible
|
||||||
|
if (fg === bg) bg = fg === 0 ? 7 : 0 |
||||||
|
|
||||||
|
this.drawBackground({ x, y, cellWidth, cellHeight, bg }) |
||||||
|
this.drawCharacter({ |
||||||
|
x, y, charSize, cellWidth, cellHeight, text, fg, attrs |
||||||
|
}) |
||||||
|
ctx.restore() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (this.screen.window.graphics >= 1) ctx.restore() |
||||||
|
|
||||||
|
if (this.screen.window.debug && this.screen._debug) this.screen._debug.drawEnd() |
||||||
|
|
||||||
|
this.screen.emit('draw') |
||||||
|
} |
||||||
|
|
||||||
|
drawStatus (statusScreen) { |
||||||
|
const ctx = this.ctx |
||||||
|
const { |
||||||
|
fontFamily, |
||||||
|
width, |
||||||
|
height, |
||||||
|
devicePixelRatio |
||||||
|
} = this.screen.window |
||||||
|
|
||||||
|
// reset drawnScreen to force redraw when statusScreen is disabled
|
||||||
|
this.drawnScreen = [] |
||||||
|
|
||||||
|
const cellSize = this.screen.getCellSize() |
||||||
|
const screenWidth = width * cellSize.width |
||||||
|
const screenHeight = height * cellSize.height |
||||||
|
|
||||||
|
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0) |
||||||
|
ctx.clearRect(0, 0, screenWidth, screenHeight) |
||||||
|
|
||||||
|
ctx.font = `24px ${fontFamily}` |
||||||
|
ctx.fillStyle = '#fff' |
||||||
|
ctx.textAlign = 'center' |
||||||
|
ctx.textBaseline = 'middle' |
||||||
|
ctx.fillText(statusScreen.title || '', screenWidth / 2, screenHeight / 2 - 50) |
||||||
|
|
||||||
|
if (statusScreen.loading) { |
||||||
|
// show loading spinner
|
||||||
|
ctx.save() |
||||||
|
ctx.translate(screenWidth / 2, screenHeight / 2 + 20) |
||||||
|
|
||||||
|
ctx.strokeStyle = '#fff' |
||||||
|
ctx.lineWidth = 5 |
||||||
|
ctx.lineCap = 'round' |
||||||
|
|
||||||
|
let t = Date.now() / 1000 |
||||||
|
|
||||||
|
for (let i = 0; i < 12; i++) { |
||||||
|
ctx.rotate(Math.PI / 6) |
||||||
|
let offset = ((t * 12) - i) % 12 |
||||||
|
ctx.globalAlpha = Math.max(0.2, 1 - offset / 3) |
||||||
|
ctx.beginPath() |
||||||
|
ctx.moveTo(0, 15) |
||||||
|
ctx.lineTo(0, 30) |
||||||
|
ctx.stroke() |
||||||
|
} |
||||||
|
|
||||||
|
ctx.restore() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
startDrawLoop () { |
||||||
|
if (this._drawTimerThread) return |
||||||
|
let threadID = Math.random().toString(36) |
||||||
|
this._drawTimerThread = threadID |
||||||
|
this.drawTimerLoop(threadID) |
||||||
|
} |
||||||
|
|
||||||
|
stopDrawLoop () { |
||||||
|
this._drawTimerThread = null |
||||||
|
} |
||||||
|
|
||||||
|
drawTimerLoop (threadID) { |
||||||
|
if (!threadID || threadID !== this._drawTimerThread) return |
||||||
|
window.requestAnimationFrame(() => this.drawTimerLoop(threadID)) |
||||||
|
this.draw('draw-loop') |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Converts an alphabetic character to its fraktur variant. |
||||||
|
* @param {string} character - the character |
||||||
|
* @returns {string} the converted character |
||||||
|
*/ |
||||||
|
static alphaToFraktur (character) { |
||||||
|
if (character >= 'a' && character <= 'z') { |
||||||
|
character = String.fromCodePoint(0x1d51e - 0x61 + character.charCodeAt(0)) |
||||||
|
} else if (character >= 'A' && character <= 'Z') { |
||||||
|
character = frakturExceptions[character] || String.fromCodePoint(0x1d504 - 0x41 + character.charCodeAt(0)) |
||||||
|
} |
||||||
|
return character |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,111 @@ |
|||||||
|
|
||||||
|
const themes = exports.themes = [ |
||||||
|
[ // Tango
|
||||||
|
'#111213', '#CC0000', '#4E9A06', '#C4A000', '#3465A4', '#75507B', '#06989A', '#D3D7CF', |
||||||
|
'#555753', '#EF2929', '#8AE234', '#FCE94F', '#729FCF', '#AD7FA8', '#34E2E2', '#EEEEEC' |
||||||
|
], |
||||||
|
[ // Linux (CGA)
|
||||||
|
'#000000', '#aa0000', '#00aa00', '#aa5500', '#0000aa', '#aa00aa', '#00aaaa', '#aaaaaa', |
||||||
|
'#555555', '#ff5555', '#55ff55', '#ffff55', '#5555ff', '#ff55ff', '#55ffff', '#ffffff' |
||||||
|
], |
||||||
|
[ // xterm
|
||||||
|
'#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000ee', '#cd00cd', '#00cdcd', '#e5e5e5', |
||||||
|
'#7f7f7f', '#ff0000', '#00ff00', '#ffff00', '#5c5cff', '#ff00ff', '#00ffff', '#ffffff' |
||||||
|
], |
||||||
|
[ // rxvt
|
||||||
|
'#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000cd', '#cd00cd', '#00cdcd', '#faebd7', |
||||||
|
'#404040', '#ff0000', '#00ff00', '#ffff00', '#0000ff', '#ff00ff', '#00ffff', '#ffffff' |
||||||
|
], |
||||||
|
[ // Ambience
|
||||||
|
'#2e3436', '#cc0000', '#4e9a06', '#c4a000', '#3465a4', '#75507b', '#06989a', '#d3d7cf', |
||||||
|
'#555753', '#ef2929', '#8ae234', '#fce94f', '#729fcf', '#ad7fa8', '#34e2e2', '#eeeeec' |
||||||
|
], |
||||||
|
[ // Solarized light
|
||||||
|
'#073642', '#dc322f', '#859900', '#b58900', '#268bd2', '#d33682', '#2aa198', '#eee8d5', |
||||||
|
'#002b36', '#cb4b16', '#586e75', '#657b83', '#839496', '#6c71c4', '#93a1a1', '#fdf6e3' |
||||||
|
], |
||||||
|
[ // CGA NTSC
|
||||||
|
'#000000', '#69001A', '#117800', '#769100', '#1A00A6', '#8019AB', '#289E76', '#A4A4A4', |
||||||
|
'#484848', '#C54E76', '#6DD441', '#D2ED46', '#765BFF', '#DC75FF', '#84FAD2', '#FFFFFF' |
||||||
|
], |
||||||
|
[ // ZX Spectrum
|
||||||
|
'#000000', '#aa0000', '#00aa00', '#aaaa00', '#0000aa', '#aa00aa', '#00aaaa', '#aaaaaa', |
||||||
|
'#000000', '#ff0000', '#00FF00', '#ffff00', '#0000ff', '#ff00ff', '#00ffff', '#ffffff' |
||||||
|
], |
||||||
|
[ // Apple II
|
||||||
|
'#000000', '#722640', '#0E5940', '#808080', '#40337F', '#E434FE', '#1B9AFE', '#BFB3FF', |
||||||
|
'#404C00', '#E46501', '#1BCB01', '#BFCC80', '#808080', '#F1A6BF', '#8DD9BF', '#ffffff' |
||||||
|
], |
||||||
|
[ // Commodore
|
||||||
|
'#000000', '#8D3E37', '#55A049', '#AAB95D', '#40318D', '#80348B', '#72C1C8', '#D59F74', |
||||||
|
'#8B5429', '#B86962', '#94E089', '#FFFFB2', '#8071CC', '#AA5FB6', '#87D6DD', '#ffffff' |
||||||
|
] |
||||||
|
] |
||||||
|
|
||||||
|
exports.fgbgThemes = [ |
||||||
|
['#AAAAAA', '#000000'], // GREY_ON_BLACK
|
||||||
|
['#EFF0F1', '#31363B'], // BREEZE
|
||||||
|
['#FFFFFF', '#000000'], // WHITE_ON_BLACK
|
||||||
|
['#00FF00', '#000000'], // GREEN_ON_BLACK
|
||||||
|
['#E53C00', '#000000'], // ORANGE_ON_BLACK
|
||||||
|
['#FFFFFF', '#300A24'], // AMBIENCE
|
||||||
|
['#839496', '#002B36'], // SOLARIZED_DARK
|
||||||
|
['#657B83', '#FDF6E3'], // SOLARIZED_LIGHT
|
||||||
|
['#000000', '#FFFFDD'], // BLACK_ON_YELLOW
|
||||||
|
['#000000', '#FFFFFF'] // BLACK_ON_WHITE
|
||||||
|
] |
||||||
|
|
||||||
|
let colorTable256 = null |
||||||
|
|
||||||
|
exports.buildColorTable = function () { |
||||||
|
if (colorTable256 !== null) return colorTable256 |
||||||
|
|
||||||
|
// 256color lookup table
|
||||||
|
// should not be used to look up 0-15 (will return transparent)
|
||||||
|
colorTable256 = new Array(16).fill('rgba(0, 0, 0, 0)') |
||||||
|
|
||||||
|
// fill color table
|
||||||
|
// colors 16-231 are a 6x6x6 color cube
|
||||||
|
for (let red = 0; red < 6; red++) { |
||||||
|
for (let green = 0; green < 6; green++) { |
||||||
|
for (let blue = 0; blue < 6; blue++) { |
||||||
|
let redValue = red * 40 + (red ? 55 : 0) |
||||||
|
let greenValue = green * 40 + (green ? 55 : 0) |
||||||
|
let blueValue = blue * 40 + (blue ? 55 : 0) |
||||||
|
colorTable256.push(`rgb(${redValue}, ${greenValue}, ${blueValue})`) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
// colors 232-255 are a grayscale ramp, sans black and white
|
||||||
|
for (let gray = 0; gray < 24; gray++) { |
||||||
|
let value = gray * 10 + 8 |
||||||
|
colorTable256.push(`rgb(${value}, ${value}, ${value})`) |
||||||
|
} |
||||||
|
|
||||||
|
return colorTable256 |
||||||
|
} |
||||||
|
|
||||||
|
exports.SELECTION_FG = '#333' |
||||||
|
exports.SELECTION_BG = '#b2d7fe' |
||||||
|
|
||||||
|
function resolveColor (themeN, shade) { |
||||||
|
shade = +shade |
||||||
|
if (shade < 16) shade = themes[themeN][shade] |
||||||
|
else { |
||||||
|
shade = exports.buildColorTable()[shade] |
||||||
|
} |
||||||
|
return shade |
||||||
|
} |
||||||
|
|
||||||
|
exports.themePreview = function (n) { |
||||||
|
document.querySelectorAll('[data-fg]').forEach((elem) => { |
||||||
|
let shade = elem.dataset.fg |
||||||
|
if (/^\d+$/.test(shade)) shade = resolveColor(n, shade) |
||||||
|
elem.style.color = shade |
||||||
|
}) |
||||||
|
document.querySelectorAll('[data-bg]').forEach((elem) => { |
||||||
|
let shade = elem.dataset.bg |
||||||
|
if (/^\d+$/.test(shade)) shade = resolveColor(n, shade) |
||||||
|
elem.style.backgroundColor = shade |
||||||
|
}) |
||||||
|
} |
@ -1,144 +0,0 @@ |
|||||||
/** Handle connections */ |
|
||||||
window.Conn = function (screen) { |
|
||||||
let ws |
|
||||||
let heartbeatTout |
|
||||||
let pingIv |
|
||||||
let xoff = false |
|
||||||
let autoXoffTout |
|
||||||
let reconTout |
|
||||||
|
|
||||||
let pageShown = false |
|
||||||
|
|
||||||
function onOpen (evt) { |
|
||||||
console.log('CONNECTED') |
|
||||||
heartbeat() |
|
||||||
doSend('i') |
|
||||||
} |
|
||||||
|
|
||||||
function onClose (evt) { |
|
||||||
console.warn('SOCKET CLOSED, code ' + evt.code + '. Reconnecting...') |
|
||||||
clearTimeout(reconTout) |
|
||||||
reconTout = setTimeout(function () { |
|
||||||
init() |
|
||||||
}, 2000) |
|
||||||
// this happens when the buffer gets fucked up via invalid unicode.
|
|
||||||
// we basically use polling instead of socket then
|
|
||||||
} |
|
||||||
|
|
||||||
function onMessage (evt) { |
|
||||||
try { |
|
||||||
// . = heartbeat
|
|
||||||
switch (evt.data.charAt(0)) { |
|
||||||
case '.': |
|
||||||
// heartbeat, no-op message
|
|
||||||
break |
|
||||||
|
|
||||||
case '-': |
|
||||||
// console.log('xoff');
|
|
||||||
xoff = true |
|
||||||
autoXoffTout = setTimeout(function () { |
|
||||||
xoff = false |
|
||||||
}, 250) |
|
||||||
break |
|
||||||
|
|
||||||
case '+': |
|
||||||
// console.log('xon');
|
|
||||||
xoff = false |
|
||||||
clearTimeout(autoXoffTout) |
|
||||||
break |
|
||||||
|
|
||||||
default: |
|
||||||
screen.load(evt.data) |
|
||||||
if (!pageShown) { |
|
||||||
showPage() |
|
||||||
pageShown = true |
|
||||||
} |
|
||||||
break |
|
||||||
} |
|
||||||
heartbeat() |
|
||||||
} catch (e) { |
|
||||||
console.error(e) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
function canSend () { |
|
||||||
return !xoff |
|
||||||
} |
|
||||||
|
|
||||||
function doSend (message) { |
|
||||||
if (_demo) { |
|
||||||
if (typeof demoInterface !== 'undefined') { |
|
||||||
demoInterface.input(message) |
|
||||||
} else { |
|
||||||
console.log(`TX: ${JSON.stringify(message)}`) |
|
||||||
} |
|
||||||
return true // Simulate success
|
|
||||||
} |
|
||||||
if (xoff) { |
|
||||||
// TODO queue
|
|
||||||
console.log("Can't send, flood control.") |
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
if (!ws) return false // for dry testing
|
|
||||||
if (ws.readyState !== 1) { |
|
||||||
console.error('Socket not ready') |
|
||||||
return false |
|
||||||
} |
|
||||||
if (typeof message != 'string') { |
|
||||||
message = JSON.stringify(message) |
|
||||||
} |
|
||||||
ws.send(message) |
|
||||||
return true |
|
||||||
} |
|
||||||
|
|
||||||
function init () { |
|
||||||
if (window._demo) { |
|
||||||
if (typeof demoInterface === 'undefined') { |
|
||||||
alert('Demoing non-demo demo!') // this will catch mistakes when deploying to the website
|
|
||||||
} else { |
|
||||||
demoInterface.init(screen) |
|
||||||
showPage() |
|
||||||
} |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
clearTimeout(reconTout) |
|
||||||
clearTimeout(heartbeatTout) |
|
||||||
|
|
||||||
ws = new WebSocket('ws://' + _root + '/term/update.ws') |
|
||||||
ws.onopen = onOpen |
|
||||||
ws.onclose = onClose |
|
||||||
ws.onmessage = onMessage |
|
||||||
console.log('Opening socket.') |
|
||||||
heartbeat() |
|
||||||
} |
|
||||||
|
|
||||||
function heartbeat () { |
|
||||||
clearTimeout(heartbeatTout) |
|
||||||
heartbeatTout = setTimeout(heartbeatFail, 2000) |
|
||||||
} |
|
||||||
|
|
||||||
function heartbeatFail () { |
|
||||||
console.error('Heartbeat lost, probing server...') |
|
||||||
pingIv = setInterval(function () { |
|
||||||
console.log('> ping') |
|
||||||
$.get('http://' + _root + '/system/ping', function (resp, status) { |
|
||||||
if (status === 200) { |
|
||||||
clearInterval(pingIv) |
|
||||||
console.info('Server ready, reloading page...') |
|
||||||
location.reload() |
|
||||||
} |
|
||||||
}, { |
|
||||||
timeout: 100 |
|
||||||
}) |
|
||||||
}, 1000) |
|
||||||
} |
|
||||||
|
|
||||||
return { |
|
||||||
ws: null, |
|
||||||
init, |
|
||||||
send: doSend, |
|
||||||
canSend // check flood control
|
|
||||||
} |
|
||||||
} |
|
@ -1,303 +0,0 @@ |
|||||||
/** |
|
||||||
* User input |
|
||||||
* |
|
||||||
* --- Rx messages: --- |
|
||||||
* S - screen content (binary encoding of the entire screen with simple compression) |
|
||||||
* T - text labels - Title and buttons, \0x01-separated |
|
||||||
* B - beep |
|
||||||
* . - heartbeat |
|
||||||
* |
|
||||||
* --- Tx messages --- |
|
||||||
* s - string |
|
||||||
* b - action button |
|
||||||
* p - mb press |
|
||||||
* r - mb release |
|
||||||
* m - mouse move |
|
||||||
*/ |
|
||||||
window.Input = function (conn) { |
|
||||||
let cfg = { |
|
||||||
np_alt: false, |
|
||||||
cu_alt: false, |
|
||||||
fn_alt: false, |
|
||||||
mt_click: false, |
|
||||||
mt_move: false, |
|
||||||
no_keys: false, |
|
||||||
crlf_mode: false |
|
||||||
} |
|
||||||
|
|
||||||
/** Send a literal message */ |
|
||||||
function sendStrMsg (str) { |
|
||||||
return conn.send('s' + str) |
|
||||||
} |
|
||||||
|
|
||||||
/** Send a button event */ |
|
||||||
function sendBtnMsg (n) { |
|
||||||
conn.send('b' + String.fromCharCode(n)) |
|
||||||
} |
|
||||||
|
|
||||||
/** Fn alt choice for key message */ |
|
||||||
function fa (alt, normal) { |
|
||||||
return cfg.fn_alt ? alt : normal |
|
||||||
} |
|
||||||
|
|
||||||
/** Cursor alt choice for key message */ |
|
||||||
function ca (alt, normal) { |
|
||||||
return cfg.cu_alt ? alt : normal |
|
||||||
} |
|
||||||
|
|
||||||
/** Numpad alt choice for key message */ |
|
||||||
function na (alt, normal) { |
|
||||||
return cfg.np_alt ? alt : normal |
|
||||||
} |
|
||||||
|
|
||||||
function bindFnKeys (allFn) { |
|
||||||
const keymap = { |
|
||||||
'tab': '\x09', |
|
||||||
'backspace': '\x08', |
|
||||||
'enter': cfg.crlf_mode ? '\x0d\x0a' : '\x0d', |
|
||||||
'ctrl+enter': '\x0a', |
|
||||||
'esc': '\x1b', |
|
||||||
'up': ca('\x1bOA', '\x1b[A'), |
|
||||||
'down': ca('\x1bOB', '\x1b[B'), |
|
||||||
'right': ca('\x1bOC', '\x1b[C'), |
|
||||||
'left': ca('\x1bOD', '\x1b[D'), |
|
||||||
'home': ca('\x1bOH', fa('\x1b[H', '\x1b[1~')), |
|
||||||
'insert': '\x1b[2~', |
|
||||||
'delete': '\x1b[3~', |
|
||||||
'end': ca('\x1bOF', fa('\x1b[F', '\x1b[4~')), |
|
||||||
'pageup': '\x1b[5~', |
|
||||||
'pagedown': '\x1b[6~', |
|
||||||
'f1': fa('\x1bOP', '\x1b[11~'), |
|
||||||
'f2': fa('\x1bOQ', '\x1b[12~'), |
|
||||||
'f3': fa('\x1bOR', '\x1b[13~'), |
|
||||||
'f4': fa('\x1bOS', '\x1b[14~'), |
|
||||||
'f5': '\x1b[15~', // note the disconnect
|
|
||||||
'f6': '\x1b[17~', |
|
||||||
'f7': '\x1b[18~', |
|
||||||
'f8': '\x1b[19~', |
|
||||||
'f9': '\x1b[20~', |
|
||||||
'f10': '\x1b[21~', // note the disconnect
|
|
||||||
'f11': '\x1b[23~', |
|
||||||
'f12': '\x1b[24~', |
|
||||||
'shift+f1': fa('\x1bO1;2P', '\x1b[25~'), |
|
||||||
'shift+f2': fa('\x1bO1;2Q', '\x1b[26~'), // note the disconnect
|
|
||||||
'shift+f3': fa('\x1bO1;2R', '\x1b[28~'), |
|
||||||
'shift+f4': fa('\x1bO1;2S', '\x1b[29~'), // note the disconnect
|
|
||||||
'shift+f5': fa('\x1b[15;2~', '\x1b[31~'), |
|
||||||
'shift+f6': fa('\x1b[17;2~', '\x1b[32~'), |
|
||||||
'shift+f7': fa('\x1b[18;2~', '\x1b[33~'), |
|
||||||
'shift+f8': fa('\x1b[19;2~', '\x1b[34~'), |
|
||||||
'shift+f9': fa('\x1b[20;2~', '\x1b[35~'), // 35-38 are not standard - but what is?
|
|
||||||
'shift+f10': fa('\x1b[21;2~', '\x1b[36~'), |
|
||||||
'shift+f11': fa('\x1b[22;2~', '\x1b[37~'), |
|
||||||
'shift+f12': fa('\x1b[23;2~', '\x1b[38~'), |
|
||||||
'np_0': na('\x1bOp', '0'), |
|
||||||
'np_1': na('\x1bOq', '1'), |
|
||||||
'np_2': na('\x1bOr', '2'), |
|
||||||
'np_3': na('\x1bOs', '3'), |
|
||||||
'np_4': na('\x1bOt', '4'), |
|
||||||
'np_5': na('\x1bOu', '5'), |
|
||||||
'np_6': na('\x1bOv', '6'), |
|
||||||
'np_7': na('\x1bOw', '7'), |
|
||||||
'np_8': na('\x1bOx', '8'), |
|
||||||
'np_9': na('\x1bOy', '9'), |
|
||||||
'np_mul': na('\x1bOR', '*'), |
|
||||||
'np_add': na('\x1bOl', '+'), |
|
||||||
'np_sub': na('\x1bOS', '-'), |
|
||||||
'np_point': na('\x1bOn', '.'), |
|
||||||
'np_div': na('\x1bOQ', '/') |
|
||||||
// we don't implement numlock key (should change in numpad_alt mode,
|
|
||||||
// but it's even more useless than the rest and also has the side
|
|
||||||
// effect of changing the user's numlock state)
|
|
||||||
} |
|
||||||
|
|
||||||
const blacklist = [ |
|
||||||
'f5', 'f11', 'f12', 'shift+f5' |
|
||||||
] |
|
||||||
|
|
||||||
for (let k in keymap) { |
|
||||||
if (!allFn && blacklist.includes(k)) continue |
|
||||||
if (keymap.hasOwnProperty(k)) { |
|
||||||
bind(k, keymap[k]) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** Bind a keystroke to message */ |
|
||||||
function bind (combo, str) { |
|
||||||
// mac fix - allow also cmd
|
|
||||||
if (combo.indexOf('ctrl+') !== -1) { |
|
||||||
combo += ',' + combo.replace('ctrl', 'command') |
|
||||||
} |
|
||||||
|
|
||||||
// unbind possible old binding
|
|
||||||
key.unbind(combo) |
|
||||||
|
|
||||||
key(combo, function (e) { |
|
||||||
if (cfg.no_keys) return |
|
||||||
e.preventDefault() |
|
||||||
sendStrMsg(str) |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
/** Bind/rebind key messages */ |
|
||||||
function initKeys ({ allFn }) { |
|
||||||
// This takes care of text characters typed
|
|
||||||
window.addEventListener('keypress', function (evt) { |
|
||||||
if (cfg.no_keys) return |
|
||||||
let str = '' |
|
||||||
if (evt.key) str = evt.key |
|
||||||
else if (evt.which) str = String.fromCodePoint(evt.which) |
|
||||||
if (str.length > 0 && str.charCodeAt(0) >= 32) { |
|
||||||
// console.log("Typed ", str);
|
|
||||||
// prevent space from scrolling
|
|
||||||
if (evt.which === 32) evt.preventDefault() |
|
||||||
sendStrMsg(str) |
|
||||||
} |
|
||||||
}) |
|
||||||
|
|
||||||
// ctrl-letter codes are sent as simple low ASCII codes
|
|
||||||
for (let i = 1; i <= 26; i++) { |
|
||||||
bind('ctrl+' + String.fromCharCode(96 + i), String.fromCharCode(i)) |
|
||||||
} |
|
||||||
/* eslint-disable */ |
|
||||||
bind('ctrl+]', '\x1b') // alternate way to enter ESC
|
|
||||||
bind('ctrl+\\', '\x1c') |
|
||||||
bind('ctrl+[', '\x1d') |
|
||||||
bind('ctrl+^', '\x1e') |
|
||||||
bind('ctrl+_', '\x1f') |
|
||||||
|
|
||||||
// extra ctrl-
|
|
||||||
bind('ctrl+left', '\x1f[1;5D') |
|
||||||
bind('ctrl+right', '\x1f[1;5C') |
|
||||||
bind('ctrl+up', '\x1f[1;5A') |
|
||||||
bind('ctrl+down', '\x1f[1;5B') |
|
||||||
bind('ctrl+home', '\x1f[1;5H') |
|
||||||
bind('ctrl+end', '\x1f[1;5F') |
|
||||||
|
|
||||||
// extra shift-
|
|
||||||
bind('shift+left', '\x1f[1;2D') |
|
||||||
bind('shift+right', '\x1f[1;2C') |
|
||||||
bind('shift+up', '\x1f[1;2A') |
|
||||||
bind('shift+down', '\x1f[1;2B') |
|
||||||
bind('shift+home', '\x1f[1;2H') |
|
||||||
bind('shift+end', '\x1f[1;2F') |
|
||||||
|
|
||||||
// macOS editing commands
|
|
||||||
bind('⌥+left', '\x1bb') // ⌥← to go back a word (^[b)
|
|
||||||
bind('⌥+right', '\x1bf') // ⌥→ to go forward one word (^[f)
|
|
||||||
bind('⌘+left', '\x01') // ⌘← to go to the beginning of a line (^A)
|
|
||||||
bind('⌘+right', '\x05') // ⌘→ to go to the end of a line (^E)
|
|
||||||
bind('⌥+backspace', '\x17') // ⌥⌫ to delete a word (^W)
|
|
||||||
bind('⌘+backspace', '\x15') // ⌘⌫ to delete to the beginning of a line (^U)
|
|
||||||
/* eslint-enable */ |
|
||||||
|
|
||||||
bindFnKeys(allFn) |
|
||||||
} |
|
||||||
|
|
||||||
// mouse button states
|
|
||||||
let mb1 = 0 |
|
||||||
let mb2 = 0 |
|
||||||
let mb3 = 0 |
|
||||||
|
|
||||||
/** Init the Input module */ |
|
||||||
function init (opts) { |
|
||||||
initKeys(opts) |
|
||||||
|
|
||||||
// Button presses
|
|
||||||
$('#action-buttons button').forEach(s => { |
|
||||||
s.addEventListener('click', function (evt) { |
|
||||||
sendBtnMsg(+this.dataset['n']) |
|
||||||
}) |
|
||||||
}) |
|
||||||
|
|
||||||
// global mouse state tracking - for motion reporting
|
|
||||||
window.addEventListener('mousedown', evt => { |
|
||||||
if (evt.button === 0) mb1 = 1 |
|
||||||
if (evt.button === 1) mb2 = 1 |
|
||||||
if (evt.button === 2) mb3 = 1 |
|
||||||
}) |
|
||||||
|
|
||||||
window.addEventListener('mouseup', evt => { |
|
||||||
if (evt.button === 0) mb1 = 0 |
|
||||||
if (evt.button === 1) mb2 = 0 |
|
||||||
if (evt.button === 2) mb3 = 0 |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
/** Prepare modifiers byte for mouse message */ |
|
||||||
function packModifiersForMouse () { |
|
||||||
return (key.isModifier('ctrl') ? 1 : 0) | |
|
||||||
(key.isModifier('shift') ? 2 : 0) | |
|
||||||
(key.isModifier('alt') ? 4 : 0) | |
|
||||||
(key.isModifier('meta') ? 8 : 0) |
|
||||||
} |
|
||||||
|
|
||||||
return { |
|
||||||
/** Init the Input module */ |
|
||||||
init, |
|
||||||
|
|
||||||
/** Send a literal string message */ |
|
||||||
sendString: sendStrMsg, |
|
||||||
|
|
||||||
/** Enable alternate key modes (cursors, numpad, fn) */ |
|
||||||
setAlts: function (cu, np, fn, crlf) { |
|
||||||
if (cfg.cu_alt !== cu || cfg.np_alt !== np || cfg.fn_alt !== fn || cfg.crlf_mode !== crlf) { |
|
||||||
cfg.cu_alt = cu |
|
||||||
cfg.np_alt = np |
|
||||||
cfg.fn_alt = fn |
|
||||||
cfg.crlf_mode = crlf |
|
||||||
|
|
||||||
// rebind keys - codes have changed
|
|
||||||
bindFnKeys() |
|
||||||
} |
|
||||||
}, |
|
||||||
|
|
||||||
setMouseMode (click, move) { |
|
||||||
cfg.mt_click = click |
|
||||||
cfg.mt_move = move |
|
||||||
}, |
|
||||||
|
|
||||||
// Mouse events
|
|
||||||
onMouseMove (x, y) { |
|
||||||
if (!cfg.mt_move) return |
|
||||||
const b = mb1 ? 1 : mb2 ? 2 : mb3 ? 3 : 0 |
|
||||||
const m = packModifiersForMouse() |
|
||||||
conn.send('m' + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m)) |
|
||||||
}, |
|
||||||
|
|
||||||
onMouseDown (x, y, b) { |
|
||||||
if (!cfg.mt_click) return |
|
||||||
if (b > 3 || b < 1) return |
|
||||||
const m = packModifiersForMouse() |
|
||||||
conn.send('p' + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m)) |
|
||||||
// console.log("B ",b," M ",m);
|
|
||||||
}, |
|
||||||
|
|
||||||
onMouseUp (x, y, b) { |
|
||||||
if (!cfg.mt_click) return |
|
||||||
if (b > 3 || b < 1) return |
|
||||||
const m = packModifiersForMouse() |
|
||||||
conn.send('r' + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m)) |
|
||||||
// console.log("B ",b," M ",m);
|
|
||||||
}, |
|
||||||
|
|
||||||
onMouseWheel (x, y, dir) { |
|
||||||
if (!cfg.mt_click) return |
|
||||||
// -1 ... btn 4 (away from user)
|
|
||||||
// +1 ... btn 5 (towards user)
|
|
||||||
const m = packModifiersForMouse() |
|
||||||
const b = (dir < 0 ? 4 : 5) |
|
||||||
conn.send('p' + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m)) |
|
||||||
// console.log("B ",b," M ",m);
|
|
||||||
}, |
|
||||||
|
|
||||||
/** |
|
||||||
* Prevent capturing keys. This is used for text input |
|
||||||
* modals on the terminal screen |
|
||||||
*/ |
|
||||||
blockKeys (yes) { |
|
||||||
cfg.no_keys = yes |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,41 @@ |
|||||||
|
const webpack = require('webpack') |
||||||
|
const { execSync } = require('child_process') |
||||||
|
const path = require('path') |
||||||
|
|
||||||
|
let hash = execSync('git rev-parse --short HEAD').toString().trim() |
||||||
|
|
||||||
|
let plugins = [] |
||||||
|
let devtool = 'source-map' |
||||||
|
|
||||||
|
if (process.env.ESP_PROD) { |
||||||
|
// ignore demo
|
||||||
|
plugins.push(new webpack.IgnorePlugin(/\.\/demo(?:\.js)?$/)) |
||||||
|
|
||||||
|
// no source maps
|
||||||
|
devtool = '' |
||||||
|
} |
||||||
|
|
||||||
|
plugins.push(new webpack.optimize.UglifyJsPlugin({ |
||||||
|
sourceMap: devtool === 'source-map' |
||||||
|
})) |
||||||
|
|
||||||
|
module.exports = { |
||||||
|
entry: './js', |
||||||
|
output: { |
||||||
|
path: path.resolve(__dirname, 'out', 'js'), |
||||||
|
filename: `app.${hash}.js` |
||||||
|
}, |
||||||
|
module: { |
||||||
|
rules: [ |
||||||
|
{ |
||||||
|
test: /\.js$/, |
||||||
|
exclude: [ |
||||||
|
path.resolve(__dirname, 'node_modules') |
||||||
|
], |
||||||
|
loader: 'babel-loader' |
||||||
|
} |
||||||
|
] |
||||||
|
}, |
||||||
|
devtool, |
||||||
|
plugins |
||||||
|
} |
Loading…
Reference in new issue