diff --git a/MANIFEST.in b/MANIFEST.in index 2916540..343826d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ recursive-include example * -recursive-include pynetworktables2js/js * \ No newline at end of file +recursive-include pynetworktables2js/js * +recursive-include pynetworktables2js/css * \ No newline at end of file diff --git a/example/www/tableviewer.html b/example/www/tableviewer.html new file mode 100644 index 0000000..fd2908f --- /dev/null +++ b/example/www/tableviewer.html @@ -0,0 +1,30 @@ + + + + + + + + +
+ + + + + + + + + + + + + + diff --git a/pynetworktables2js/css/tableviewer.css b/pynetworktables2js/css/tableviewer.css new file mode 100644 index 0000000..32aa441 --- /dev/null +++ b/pynetworktables2js/css/tableviewer.css @@ -0,0 +1,212 @@ + +.tableviewer { + position: relative; +} + +.tableviewer ul, .tableviewer li { + list-style: none; +} + +.tableviewer ul { + padding-left: 0; +} + +.tableviewer li.boolean, .tableviewer li.number, .tableviewer li.string { + padding-left: 25px; +} + +.tableviewer li.table, .tableviewer li.array { + padding-left: 19px; +} + +.tableviewer > ul { + padding-left: 0; +} + +.tableviewer button.expanded, .tableviewer button.collapsed { + background: none; + border: none; + outline: none; +} + +.tableviewer button.expanded::before { + content: "▼"; +} + +.tableviewer button.collapsed::before { + content: "▶"; +} + +.tableviewer button.collapsed + ul, .tableviewer button.collapsed + span + ul { + display: none; +} + + +.tableviewer .key::after { + content: ":"; + display: inline-block; + padding: 0 10px; +} + +.tableviewer span.type { + text-transform: capitalize; + color: #aaa; + font-size: x-small; + margin-left: 13px; + display: inline-block; + bottom: 2px; + position: relative; +} + +.tableviewer .number span.type { + margin-left: 0; +} + + +.tableviewer input[type=number] { + position: relative; + border: none; + outline: none; + padding-left: 10px; + width: 100%; + text-overflow: ellipsis; +} + +.tableviewer li.string, .tableviewer li.number { + display: flex; + flex-wrap: nowrap; + align-items: center; +} + +.tableviewer li.string .key, .tableviewer li.number .key { + display: flex; +} + +.tableviewer input[type="number"]::-webkit-outer-spin-button, +.tableviewer input[type="number"]::-webkit-inner-spin-button { + position: absolute; + left: -5px; +} + + +.tableviewer input[type=text] { + width: 100%; + border: none; + outline: none; + text-overflow: ellipsis; + margin: 0 3px; +} + +.tableviewer .phantom-input { + position: absolute; + visibility: hidden; + -webkit-user-select: none; +} + +.tableviewer input, .tableviewer .phantom-input { + font-family: "Times New Roman", Times, serif; + font-size: 12px; +} + +.tableviewer .array ul .type { + display: none; +} + + + +.tableviewer-modal { + position: fixed; + left: 0; + right: 0; + top: 100px; + width: 300px; + z-index: 200; + background-color: white; + border: 2px solid #aaa; + margin: 0 auto; + padding: 10px; +} + +.tableviewer-modal .close { + position: absolute; + top: 0; + right: 0; + padding: 7px; + cursor: pointer; + line-height: 8px; +} + +.tableviewer-modal .title { + font-weight: bold; + margin-bottom: 12px; + font-size: larger; +} + +.tableviewer-modal .input-row { + display: flex; + margin-bottom: 8px; +} + +.tableviewer-modal .input-row label { + display: inline-block; + margin-right: 8px; +} + +.tableviewer-modal .input-row input { + flex: 1; +} + +.tableviewer-modal-overlay { + position: absolute; + width: 100%; + height: 100%; + background-color: rgba(255,255,255,.8); + left: 0; + right: 0; + top: 0; + z-index: 100; +} + +.tableviewer-modal .buttons { + text-align: right; +} + +.tableviewer-modal .buttons button { + text-transform: uppercase; + padding: 5px 10px; + margin: 0 3px; +} + +.tableviewer-modal .body:not([data-type=string]) .add-string { + display: none; +} + +.tableviewer-modal .body:not([data-type=number]) .add-number { + display: none; +} + +.tableviewer-modal .body:not([data-type=boolean]) .add-boolean { + display: none; +} + +.tableviewer-modal .title .type-label { + text-transform: capitalize; +} + +.tableviewer .contextmenu { + position: absolute; + border: 1px solid black; +} + +.tableviewer .contextmenu span { + display: block; + width: 120px; + padding: 5px 10px; + text-align: left; + background: white; + cursor: pointer; +} + +.tableviewer .contextmenu span:hover { + background: #aaa; +} \ No newline at end of file diff --git a/pynetworktables2js/js/tableviewer.js b/pynetworktables2js/js/tableviewer.js new file mode 100644 index 0000000..5373dc4 --- /dev/null +++ b/pynetworktables2js/js/tableviewer.js @@ -0,0 +1,491 @@ +"use strict"; + +(function() { + + if ($ === undefined) { + alert("jQuery must be downloaded+included to use tableviewer.js!"); + return; + } + + function Tableviewer($el) { + this.$el = $el.addClass('tableviewer'); + var $root = $('
Root
') + .appendTo($el); + this.ntRoot = { + '' : { + type : 'table', + $el : $root.find('ul') + } + }; + + this._createModal(); + + // Expand/Collabse tables + $el.on('click', '.expanded, .collapsed', function(e) { + $(this).toggleClass('expanded collapsed'); + $(this).siblings().find('input[type=number], input[type=text]').trigger('tableExpanded'); + }); + + // Resize number input based on length of input + $el.on('change keyup ntUpdate tableExpanded', '[type=number]', function(e) { + var text = $(this).val(); + var length = text.length; + var $phantomInput = $(this).next(); + $phantomInput.text(text); + $(this).css('max-width', 13 + $phantomInput.width()); + }); + + $el.on('change keyup ntUpdate tableExpanded', '[type=text]', function(e) { + var text = $(this).val(); + var length = text.length; + var $phantomInput = $(this).next(); + $phantomInput.text(text); + $(this).css('max-width', 10 + $phantomInput.width()); + }); + + // Update NetworkTables + $el.on('change', '[type=checkbox]', function(e) { + var key = $(this).parents('[data-path]').data('path'); + var value = $(this).prop('checked'); + NetworkTables.putValue(key, value); + }); + + $el.on('change', '[type=text]', function(e) { + var key = $(this).parents('[data-path]').data('path'); + var value = $(this).val(); + NetworkTables.putValue(key, value); + }); + + $el.on('change', '[type=number]', function(e) { + var key = $(this).parents('[data-path]').data('path'); + var value = parseFloat($(this).val()); + NetworkTables.putValue(key, value); + }); + + var that = this; + + // Show context menu for inserting values + this._createContextMenu(); + + + + NetworkTables.addGlobalListener(function(key, value, isNew) { + that._putValue(key, value, 0); + }, true); + } + + Tableviewer.prototype._createContextMenu = function() { + var $contextMenu = $('').appendTo(this.$el); + + this.contextMenu = { + $el : $contextMenu, + $addString : $contextMenu.find('.add-string'), + $addNumber : $contextMenu.find('.add-number'), + $addBoolean : $contextMenu.find('.add-boolean') + }; + + var that = this; + + var key = ''; + + this.$el.on('contextmenu', '.table, .table > *', function(e) { + var $target = $(e.target); + var $table = $target.hasClass('table') ? $target : $target.parent(); + if($table.hasClass('table')) { + e.preventDefault(); + key = $table.attr('data-path'); + that._openContextMenu(e.pageX - that.$el.offset().left, e.pageY - that.$el.offset().top); + } else { + that._closeContextMenu(); + } + }); + + this.contextMenu.$addString.on('click', function(e) { + that._showModal(key + '/', 'string'); + }); + + this.contextMenu.$addNumber.on('click', function(e) { + that._showModal(key + '/', 'number'); + }); + + this.contextMenu.$addBoolean.on('click', function(e) { + that._showModal(key + '/', 'boolean'); + }); + + this.$el.on('click', function(e) { + that._closeContextMenu(); + }); + }; + + Tableviewer.prototype._openContextMenu = function(x, y) { + this.contextMenu.$el.css({ + 'display' : 'block', + 'left' : x, + 'top' : y + }); + + }; + + Tableviewer.prototype._closeContextMenu = function() { + this.contextMenu.$el.css('display', 'none'); + }; + + + Tableviewer.prototype._createModal = function() { + + var $modal = $('').appendTo('body'); + + var $modalOverlay = $('').appendTo('body'); + + this.modal = { + $el : $modal, + $body : $modal.find('.body'), + $typeLabel : $modal.find('.title .type-label'), + $close : $modal.find('.close'), + $key : $modal.find('.key input'), + $parentPath : $modal.find('.parent-path'), + $addString : $modal.find('.add-string'), + $addStringInput : $modal.find('.add-string input'), + $addNumber : $modal.find('.add-number'), + $addNumberInput : $modal.find('.add-number input'), + $addBoolean : $modal.find('.add-boolean'), + $addBooleanInput : $modal.find('.add-boolean input'), + $okButton : $modal.find('.buttons .ok'), + $cancelButton : $modal.find('.buttons .cancel'), + $overlay : $modalOverlay + }; + + + // Add events + var that = this; + + var closeSelectors = [ + '.tableviewer-modal .close', + '.tableviewer-modal .buttons .cancel', + '.tableviewer-modal-overlay' + ]; + + $('body').on('click', closeSelectors.join(','), function(e) { + that._hideModal(); + }); + + // Put element into NetworkTables + this.modal.$okButton.on('click', function(e) { + var type = that.modal.$body.attr('data-type'); + var key = that.modal.$parentPath.val() + that.modal.$key.val(); + var value; + + if(type === 'string') { + value = that.modal.$addStringInput.val(); + } else if(type ==='number') { + value = parseFloat(that.modal.$addNumberInput.val()); + } else if(type === 'boolean') { + value = that.modal.$addBooleanInput.prop('checked'); + } + + NetworkTables.putValue(key, value); + + that._hideModal(); + }); + + }; + + Tableviewer.prototype._showModal = function(parentPath, type) { + + type = ['string', 'number', 'boolean', 'array'].indexOf(type) > -1 ? type : 'string'; + + this.modal.$parentPath.val(parentPath ? parentPath : ''); + this.modal.$key.val(''); + this.modal.$body.attr('data-type', type); + this.modal.$typeLabel.text(type); + + this.modal.$el.css('display', 'block'); + this.modal.$overlay.css('display', 'block'); + }; + + Tableviewer.prototype._hideModal = function() { + this.modal.$el.css('display', 'none'); + this.modal.$overlay.css('display', 'none'); + }; + + Tableviewer.prototype.printTable = function() { + console.log(this.ntRoot); + }; + + Tableviewer.prototype._putValue = function(key, value) { + var steps = key.split('/').filter(function(s) { return s.length > 0; }); + + var parentPath = ''; + + for(var i = 0 ; i < steps.length - 1; i++) { + if(!this._createTableNode(parentPath, steps[i])) { + return; + } + parentPath += '/' + steps[i]; + } + + var type = typeof(value); + var step = steps[steps.length - 1]; + var path = parentPath + '/' + step; + + // Create node if it doesn't exist + if(!this.ntRoot[path]) { + if(type === 'object') { + this._createArrayNode(parentPath, step, value); + } else if(type === 'boolean') { + this._createBooleanNode(parentPath, step, value); + } else if(type === 'number') { + this._createNumberNode(parentPath, step, value); + } else { + this._createStringNode(parentPath, step, value); + } + } + + // Update the value + if(type === 'object') { + this._updateArray(path, value); + } else if(type === 'boolean') { + this._updateBoolean(path, value); + } else if(type === 'number') { + this._updateNumber(path, value); + } else { + this._updateString(path, value); + } + }; + + Tableviewer.prototype._updateArray = function(path, values) { + // this.ntRoot[pathTraveled].$type.text(value.type + '[' + value.length + ']'); + var type = 'array'; + + for(var i = 0; i < values.length; i++) { + var value = values[i]; + type = typeof(value); + + // Create array elements + if(!this.ntRoot[path + '/' + i]) { + if(type === 'boolean') { + this._createBooleanNode(path, i, value); + } else if(type === 'number') { + this._createNumberNode(path, i, value); + } else { + this._createStringNode(path, i, value); + } + + // Disable the input + this.ntRoot[path + '/' + i].$value.prop('disabled', true); + } + + // Update the value + if(type === 'boolean') { + this._updateBoolean(path + '/' + i, value); + } else if(type === 'number') { + this._updateNumber(path + '/' + i, value); + } else { + this._updateString(path + '/' + i, value); + } + + } + + // Remove array elements + var $items = this.ntRoot[path].$el.find('li'); + for(var i = values.length; i < $items.length; i++) { + delete this.ntRoot[path + '/' + i]; + $items[i].remove(); + } + + // Set the type label + this.ntRoot[path].$type.text(type + '[' + values.length + ']'); + }; + + Tableviewer.prototype._updateBoolean = function(path, value) { + this.ntRoot[path].$value.prop('checked', value); + }; + + Tableviewer.prototype._updateNumber = function(path, value) { + this.ntRoot[path].$value.val(value); + this.ntRoot[path].$value.trigger('ntUpdate'); + }; + + Tableviewer.prototype._updateString = function(path, value) { + this.ntRoot[path].$value.val(value); + this.ntRoot[path].$value.trigger('ntUpdate'); + }; + + Tableviewer.prototype._createTableNode = function(parentPath, step) { + + var path = parentPath + '/' + step; + + // If path exists and is not a table then it's a value. The value being added/updated is invalid + if(this.ntRoot[path] && this.ntRoot[path].type !== 'table') { + return false; + } + + if(!this.ntRoot[path]) { + // Otherwise the path doesn't exist so add + var $el = $('
  • ' + step + '
  • '); + + this.ntRoot[path] = { + type : 'table', + $el : $el.find('ul'), + $root : $el, + parentPath : parentPath + } + + this._addNodeToDom(path); + } + + return true; + }; + + Tableviewer.prototype._createArrayNode = function(parentPath, step, value) { + var path = parentPath + '/' + step; + //var typeLabel = value.type + '[' + value.length + ']'; + var $el = $('
  • ' + + '' + + step + + 'Array[' + value.length + ']' + + '' + + '
  • '); + + this.ntRoot[path] = { + type : 'array', + $el : $el.find('ul'), + $root : $el, + $type : $el.find('.type'), + parentPath : parentPath + }; + + this._addNodeToDom(path); + }; + + Tableviewer.prototype._createBooleanNode = function(parentPath, step, value) { + var path = parentPath + '/' + step; + var $el = $('
  • ') + .append('' + step + '') + .append('') + .append('boolean'); + + this.ntRoot[path] = { + type : 'boolean', + $el : $el, + $root : $el, + $key : $el.find('.key'), + $value : $el.find('.value'), + $type : $el.find('.type'), + parentPath : parentPath + }; + + this._addNodeToDom(path); + }; + + Tableviewer.prototype._createNumberNode = function(parentPath, step, value) { + var path = parentPath + '/' + step; + var $el = $('
  • ') + .append('' + step + '') + .append('') + .append('') + .append('number'); + + this.ntRoot[path] = { + type : 'number', + $el : $el, + $root: $el, + $key : $el.find('.key'), + $value : $el.find('.value'), + $phantomInput : $el.find('.phantom-input'), + $type : $el.find('.type'), + parentPath : parentPath + }; + + this._addNodeToDom(path); + }; + + Tableviewer.prototype._createStringNode = function(parentPath, step, value) { + var path = parentPath + '/' + step; + var $el = $('
  • ') + .append('' + step + '') + .append('“”') + .append('') + .append('string'); + + this.ntRoot[path] = { + type : 'string', + $el : $el, + $root : $el, + $key : $el.find('.key'), + $value : $el.find('.value'), + $phantomInput : $el.find('.phantom-input'), + $type : $el.find('.type'), + parentPath : parentPath + }; + + this._addNodeToDom(path); + }; + + Tableviewer.prototype._getChildPaths = function(parentPath) { + var childPaths = []; + for(var path in this.ntRoot) { + if(this.ntRoot[path].parentPath === parentPath) { + childPaths.push(path); + } + } + return childPaths.sort(); + }; + + Tableviewer.prototype._getSiblingPaths = function(path) { + return this._getChildPaths(this.ntRoot[path].parentPath); + }; + + Tableviewer.prototype._addNodeToDom = function(path) { + var siblingPaths = this._getSiblingPaths(path); + var nodeIndex = siblingPaths.indexOf(path); + var $el = this.ntRoot[path].$root; + var parentPath = this.ntRoot[path].parentPath; + //console.log(siblingPaths); + if(nodeIndex <= 0) { + $el.prependTo(this.ntRoot[parentPath].$el); + } else { + var beforePath = siblingPaths[nodeIndex - 1]; + var $before = this.ntRoot[beforePath].$root; + $before.after($el); + } + }; + + // jQuery plugin + $.fn.extend({ + tableviewer: function() { + var args = Array.prototype.slice.call(arguments); + var method = args.shift(); + var methodArgs = args; + + return this.each(function() { + var $el = $(this); + var tableviewer = $el.data('tableviewer'); + + // Initialize tableviewer + if(typeof(tableviewer) === 'undefined') { + $el.data('tableviewer', new Tableviewer($el)); + // Call tableviewer method + } else { + Tableviewer.prototype[method].apply(tableviewer, methodArgs); + } + }); + } + }); + +})(); \ No newline at end of file diff --git a/pynetworktables2js/tornado_handlers.py b/pynetworktables2js/tornado_handlers.py index 2014a02..c1eca3d 100644 --- a/pynetworktables2js/tornado_handlers.py +++ b/pynetworktables2js/tornado_handlers.py @@ -77,10 +77,12 @@ def get_handlers(): ]) ''' + css_path_opts = {'path': abspath(join(dirname(__file__), 'css'))} js_path_opts = {'path': abspath(join(dirname(__file__), 'js'))} return [ ('/networktables/ws', NetworkTablesWebSocket), - ('/networktables/(.*)', NonCachingStaticFileHandler, js_path_opts), + ('/networktables/css/(.*)', NonCachingStaticFileHandler, css_path_opts), + ('/networktables/(.*)', NonCachingStaticFileHandler, js_path_opts) ]