- Fixed disappearing GCode in Web.
authorJoseph Coffland <joseph@cauldrondevelopment.com>
Fri, 16 Mar 2018 08:28:50 +0000 (01:28 -0700)
committerJoseph Coffland <joseph@cauldrondevelopment.com>
Fri, 16 Mar 2018 08:28:50 +0000 (01:28 -0700)
 - More efficient GCode scrolling with very large files.

CHANGELOG.md
src/jade/index.jade
src/jade/templates/control-view.jade
src/jade/templates/gcode-viewer.jade [new file with mode: 0644]
src/js/control-view.js
src/js/gcode-viewer.js [new file with mode: 0644]
src/py/bbctrl/Ctrl.py
src/resources/css/clusterize.css [new file with mode: 0644]
src/resources/js/clusterize.min.js [new file with mode: 0644]
src/stylus/style.styl

index ee79e0b8be111b197e870a29969844d8cf49600f..34c3e8c284cb8a6e0ee740d31b4cf4e4a27fd466 100644 (file)
@@ -3,6 +3,8 @@ Buildbotics CNC Controller Firmware Change Log
 
 ## v0.3.20
  - Eliminated drift caused by miscounting half microsteps.
+ - Fixed disappearing GCode in Web.
+ - More efficient GCode scrolling with very large files.
 
 ## v0.3.19
  - Fixed stopping problems. #127
index 96d549dc0a25448673dfaf627594b8e764ce3961..c25fac1bdd881d315c12264671db44e7bf77d251 100644 (file)
@@ -45,6 +45,7 @@ html(lang="en")
 
     link(rel="stylesheet" href="css/font-awesome.min.css")
     link(href="css/Audiowide.css" rel="stylesheet" type="text/css")
+    link(href="css/clusterize.css" rel="stylesheet" type="text/css")
 
     link(rel="stylesheet" href="/css/style-" + css_hash + ".css")
 
@@ -195,5 +196,6 @@ html(lang="en")
     script(src="js/jquery-1.11.3.min.js")
     script(src="js/vue.js")
     script(src="js/sockjs.min.js")
+    script(src="js/clusterize.min.js")
     script(src='/js/assets-' + js_hash + '.js')
     script(src="js/ui.js")
index 504eb60dd75955e5f0cfcd841894c654fab7ebfd..90880ee953e49c37063c623a423979e963317ef7 100644 (file)
@@ -229,13 +229,7 @@ script#control-view-template(type="text/x-template")
             :disabled="is_running || is_stopping")
             option(v-for="file in files", :value="file") {{file}}
 
-        .gcode(:class="{placeholder: !gcode}", @scroll="gcode_scroll")
-          span(v-if="!gcode.length") GCode displays here.
-          ul
-            li(v-for="item in gcode", id="gcode-line-{{$index}}",
-               track-by="$index")
-              span {{$index + 1 + gcode_offset}}
-              | {{item}}
+        gcode-viewer
 
       section#content2.tab-content
         .mdi.pure-form(title="Manual GCode entry.")
diff --git a/src/jade/templates/gcode-viewer.jade b/src/jade/templates/gcode-viewer.jade
new file mode 100644 (file)
index 0000000..c79197a
--- /dev/null
@@ -0,0 +1,33 @@
+//-/////////////////////////////////////////////////////////////////////////////
+//-                                                                           //
+//-              This file is part of the Buildbotics firmware.               //
+//-                                                                           //
+//-                Copyright (c) 2015 - 2018, Buildbotics LLC                 //
+//-                           All rights reserved.                            //
+//-                                                                           //
+//-   This file ("the software") is free software: you can redistribute it    //
+//-   and/or modify it under the terms of the GNU General Public License,     //
+//-    version 2 as published by the Free Software Foundation. You should     //
+//-    have received a copy of the GNU General Public License, version 2      //
+//-   along with the software. If not, see <http://www.gnu.org/licenses/>.    //
+//-                                                                           //
+//-   The software is distributed in the hope that it will be useful, but     //
+//-        WITHOUT ANY WARRANTY; without even the implied warranty of         //
+//-    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU      //
+//-             Lesser General Public License for more details.               //
+//-                                                                           //
+//-     You should have received a copy of the GNU Lesser General Public      //
+//-              License along with the software.  If not, see                //
+//-                     <http://www.gnu.org/licenses/>.                       //
+//-                                                                           //
+//-              For information regarding this software email:               //
+//-                "Joseph Coffland" <joseph@buildbotics.com>                 //
+//-                                                                           //
+//-/////////////////////////////////////////////////////////////////////////////
+
+script#gcode-viewer-template(type="text/x-template")
+  .gcode
+    .clusterize
+      .clusterize-scroll
+        ul.clusterize-content
+          li.clusterize-no-data.placeholder GCode displays here.
index 2d8c00bd5a9d042125eef2b084db0cf983f05d6d..f4d0be75596b5703833653224d2170bc387d7af3 100644 (file)
@@ -29,9 +29,6 @@
 
 var api = require('./api');
 
-var maxLines = 1000;
-var pageSize = Math.round(maxLines / 10);
-
 
 function _is_array(x) {
   return Object.prototype.toString.call(x) === '[object Array]';
@@ -52,12 +49,8 @@ module.exports = {
   data: function () {
     return {
       mdi: '',
-      last_file: '',
       files: [],
       axes: 'xyzabc',
-      all_gcode: [],
-      gcode: [],
-      gcode_offset: 0,
       history: [],
       speed_override: 1,
       feed_override: 1,
@@ -72,12 +65,18 @@ module.exports = {
 
 
   components: {
-    'axis-control': require('./axis-control')
+    'axis-control': require('./axis-control'),
+    'gcode-viewer': require('./gcode-viewer')
   },
 
 
   watch: {
-    'state.line': function () {this.update_gcode_line()},
+    'state.line': function () {
+      if (this.mach_state != 'HOMING')
+        this.$broadcast('gcode-line', this.state.line);
+    },
+
+
     'state.selected': function () {this.load()}
   },
 
@@ -122,9 +121,7 @@ module.exports = {
       api.put('jog', data);
     },
 
-    connected: function () {this.update()},
-
-    update: function () {console.log(this.state.xx, this.state.cycle)}
+    connected: function () {this.update()}
   },
 
 
@@ -195,86 +192,6 @@ module.exports = {
     },
 
 
-    gcode_move_up: function (count) {
-      var lines = 0;
-
-      for (var i = 0; i < count; i++) {
-        if (!this.gcode_offset) break;
-
-        this.gcode.unshift(this.all_gcode[this.gcode_offset - 1])
-        this.gcode.pop();
-        this.gcode_offset--;
-        lines++;
-      }
-
-      return lines;
-    },
-
-
-    gcode_move_down: function (count) {
-      var lines = 0;
-
-      for (var i = 0; i < count; i++) {
-        if (this.all_gcode.length <= this.gcode_offset + this.gcode.length)
-          break;
-
-        this.gcode.push(this.all_gcode[this.gcode_offset + this.gcode.length])
-        this.gcode.shift();
-        this.gcode_offset++;
-        lines++
-      }
-
-      return lines;
-    },
-
-
-    gcode_scroll: function (e) {
-      if (this.gcode.length == this.all_gcode.length) return;
-
-      var t = e.target;
-      var percentScroll = t.scrollTop / (t.scrollHeight - t.clientHeight);
-
-      var lines = 0;
-      if (percentScroll < 0.2) lines = this.gcode_move_up(pageSize);
-      else if (0.8 < percentScroll) lines = -this.gcode_move_down(pageSize);
-      else return;
-
-      if (lines) t.scrollTop += t.scrollHeight * lines / maxLines;
-    },
-
-
-    update_gcode_line: function () {
-      if (this.mach_state == 'HOMING') return;
-
-      if (typeof this.last_line != 'undefined') {
-        $('#gcode-line-' + this.last_line).removeClass('highlight');
-        this.last_line = undefined;
-      }
-
-      if (0 <= this.state.line) {
-        var line = this.state.line - 1;
-
-        // Make sure the current GCode is loaded
-        if (line < this.gcode_offset ||
-            this.gcode_offset + this.gcode.length <= line) {
-          this.gcode_offset = line - pageSize;
-          if (this.gcode_offset < 0) this.gcode_offset = 0;
-
-          this.gcode = this.all_gcode.slice(this.gcode_offset, maxLines);
-        }
-
-        Vue.nextTick(function () {
-          var e = $('#gcode-line-' + line);
-          if (e.length)
-            e.addClass('highlight')[0]
-            .scrollIntoView({behavior: 'smooth'});
-
-          this.last_line = line;
-        }.bind(this));
-      }
-    },
-
-
     update: function () {
       // Update file list
       api.get('file').done(function (files) {
@@ -284,6 +201,13 @@ module.exports = {
     },
 
 
+    load: function () {
+      var file = this.state.selected;
+      if (typeof file != 'undefined') this.$broadcast('gcode-load', file);
+      this.$broadcast('gcode-line', this.state.line);
+    },
+
+
     submit_mdi: function () {
       this.send(this.mdi);
       if (!this.history.length || this.history[0] != this.mdi)
@@ -321,28 +245,12 @@ module.exports = {
 
       api.upload('file', fd)
         .done(function () {
-          if (file.name == this.last_file) this.last_file = '';
+          this.$broadcast('gcode-reload', file.name);
           this.update();
         }.bind(this));
     },
 
 
-    load: function () {
-      var file = this.state.selected;
-      if (typeof file == 'undefined' || file == this.last_file) return;
-
-      api.get('file/' + file)
-        .done(function (data) {
-          this.all_gcode = data.trimRight().split(/\r?\n/);
-          this.gcode = this.all_gcode.slice(0, maxLines);
-          this.gcode_offset = 0;
-          this.last_file = file;
-
-          Vue.nextTick(this.update_gcode_line);
-        }.bind(this));
-    },
-
-
     deleteCurrent: function () {
       if (this.state.selected)
         api.delete('file/' + this.state.selected).done(this.update);
diff --git a/src/js/gcode-viewer.js b/src/js/gcode-viewer.js
new file mode 100644 (file)
index 0000000..ca92b7c
--- /dev/null
@@ -0,0 +1,151 @@
+/******************************************************************************\
+
+                 This file is part of the Buildbotics firmware.
+
+                   Copyright (c) 2015 - 2018, Buildbotics LLC
+                              All rights reserved.
+
+      This file ("the software") is free software: you can redistribute it
+      and/or modify it under the terms of the GNU General Public License,
+       version 2 as published by the Free Software Foundation. You should
+       have received a copy of the GNU General Public License, version 2
+      along with the software. If not, see <http://www.gnu.org/licenses/>.
+
+      The software is distributed in the hope that it will be useful, but
+           WITHOUT ANY WARRANTY; without even the implied warranty of
+       MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+                Lesser General Public License for more details.
+
+        You should have received a copy of the GNU Lesser General Public
+                 License along with the software.  If not, see
+                        <http://www.gnu.org/licenses/>.
+
+                 For information regarding this software email:
+                   "Joseph Coffland" <joseph@buildbotics.com>
+
+\******************************************************************************/
+
+'use strict'
+
+var api = require('./api');
+
+var max_lines = 1000;
+var page_size = Math.round(max_lines / 10);
+
+
+module.exports = {
+  template: '#gcode-viewer-template',
+
+
+  data: function () {
+    return {
+      empty: true,
+      file: '',
+      line: -1,
+      scrolling: false
+    }
+  },
+
+
+  events: {
+    'gcode-load': function (file) {this.load(file)},
+    'gcode-clear': function () {this.clear()},
+    'gcode-reload': function (file) {this.reload(file)},
+    'gcode-line': function (line) {this.update_line(line)}
+  },
+
+
+  methods: {
+    load: function (file) {
+      if (file == this.file) return;
+      this.clear();
+      this.file = file;
+
+      api.get('file/' + file)
+        .done(function (data) {
+          var lines = data.trimRight().split(/\r?\n/);
+
+          for (var i = 0; i < lines.length; i++) {
+            lines[i] = '<li class="line-' + (i + 1) + '">' +
+              '<span class="gcode-line">' + (i + 1) + '</span>' +
+              lines[i] + '</li>';
+          }
+
+          this.clusterize = new Clusterize({
+            rows: lines,
+            scrollElem: $(this.$el).find('.clusterize-scroll')[0],
+            contentElem: $(this.$el).find('.clusterize-content')[0],
+            callbacks: {clusterChanged: this.highlight}
+          });
+
+          this.empty = false;
+
+          Vue.nextTick(this.update_line);
+        }.bind(this));
+    },
+
+
+    clear: function () {
+      this.empty = true;
+      this.file = '';
+      this.line = -1;
+
+      if (typeof this.clusterize != 'undefined')
+        this.clusterize.destroy();
+    },
+
+
+    reload: function (file) {
+      if (file != this.file) return;
+      this.clear();
+      this.load(file);
+    },
+
+
+    highlight: function () {
+      var e = $(this.$el).find('.highlight');
+      if (e.length) e.removeClass('highlight');
+
+      e = $(this.$el).find('.line-' + this.line);
+      if (e.length) e.addClass('highlight');
+    },
+
+
+    update_line: function(line) {
+      if (typeof line != 'undefined') {
+        if (this.line == line) return;
+        this.line = line;
+
+      } else line = this.line;
+
+      if (typeof this.clusterize == 'undefined') return;
+
+      var totalLines = this.clusterize.getRowsAmount();
+
+      if (line <= 0) line = 1;
+      if (totalLines < line) line = totalLines;
+
+      var e = $(this.$el).find('.clusterize-scroll');
+
+      var lineHeight = e[0].scrollHeight / totalLines;
+      var linesPerPage = Math.floor(e[0].clientHeight / lineHeight);
+      var current = e[0].scrollTop / lineHeight;
+      var target = line - 1 - Math.floor(linesPerPage / 2);
+
+      // Update scroll position
+      if (!this.scrolling) {
+        if (target < current - 20 || current + 20 < target)
+          e[0].scrollTop = target * lineHeight;
+
+        else {
+          this.scrolling = true;
+          e.animate({scrollTop: target * lineHeight}, {
+            complete: function () {this.scrolling = false}.bind(this)
+          })
+        }
+      }
+
+      Vue.nextTick(this.highlight);
+    }
+  }
+}
index c1366cf4edcdf1eb9d8e891e71f96e176b02be12..2b0ef5246bb72268fde4f7a4652acbc0a57284b0 100644 (file)
@@ -26,7 +26,6 @@
 ################################################################################
 
 import logging
-
 import bbctrl
 
 
diff --git a/src/resources/css/clusterize.css b/src/resources/css/clusterize.css
new file mode 100644 (file)
index 0000000..dc7f878
--- /dev/null
@@ -0,0 +1,38 @@
+/* max-height - the only parameter in this file that needs to be edited.
+ * Change it to suit your needs. The rest is recommended to leave as is.
+ */
+.clusterize-scroll{
+  max-height: 200px;
+  overflow: auto;
+}
+
+/**
+ * Avoid vertical margins for extra tags
+ * Necessary for correct calculations when rows have nonzero vertical margins
+ */
+.clusterize-extra-row{
+  margin-top: 0 !important;
+  margin-bottom: 0 !important;
+}
+
+/* By default extra tag .clusterize-keep-parity added to keep parity of rows.
+ * Useful when used :nth-child(even/odd)
+ */
+.clusterize-extra-row.clusterize-keep-parity{
+  display: none;
+}
+
+/* During initialization clusterize adds tabindex to force the browser to keep focus
+ * on the scrolling list, see issue #11
+ * Outline removes default browser's borders for focused elements.
+ */
+.clusterize-content{
+  outline: 0;
+  counter-reset: clusterize-counter;
+}
+
+/* Centering message that appears when no data provided
+ */
+.clusterize-no-data td{
+  text-align: center;
+}
\ No newline at end of file
diff --git a/src/resources/js/clusterize.min.js b/src/resources/js/clusterize.min.js
new file mode 100644 (file)
index 0000000..a97f921
--- /dev/null
@@ -0,0 +1,17 @@
+/* Clusterize.js - v0.18.1 - 2018-01-02
+ http://NeXTs.github.com/Clusterize.js/
+ Copyright (c) 2015 Denis Lukov; Licensed GPLv3 */
+
+;(function(q,n){"undefined"!=typeof module?module.exports=n():"function"==typeof define&&"object"==typeof define.amd?define(n):this[q]=n()})("Clusterize",function(){function q(b,a,c){return a.addEventListener?a.addEventListener(b,c,!1):a.attachEvent("on"+b,c)}function n(b,a,c){return a.removeEventListener?a.removeEventListener(b,c,!1):a.detachEvent("on"+b,c)}function r(b){return"[object Array]"===Object.prototype.toString.call(b)}function m(b,a){return window.getComputedStyle?window.getComputedStyle(a)[b]:
+a.currentStyle[b]}var l=function(){for(var b=3,a=document.createElement("b"),c=a.all||[];a.innerHTML="\x3c!--[if gt IE "+ ++b+"]><i><![endif]--\x3e",c[0];);return 4<b?b:document.documentMode}(),x=navigator.platform.toLowerCase().indexOf("mac")+1,p=function(b){if(!(this instanceof p))return new p(b);var a=this,c={rows_in_block:50,blocks_in_cluster:4,tag:null,show_no_data_row:!0,no_data_class:"clusterize-no-data",no_data_text:"No data",keep_parity:!0,callbacks:{}};a.options={};for(var d="rows_in_block blocks_in_cluster show_no_data_row no_data_class no_data_text keep_parity tag callbacks".split(" "),
+f=0,h;h=d[f];f++)a.options[h]="undefined"!=typeof b[h]&&null!=b[h]?b[h]:c[h];c=["scroll","content"];for(f=0;d=c[f];f++)if(a[d+"_elem"]=b[d+"Id"]?document.getElementById(b[d+"Id"]):b[d+"Elem"],!a[d+"_elem"])throw Error("Error! Could not find "+d+" element");a.content_elem.hasAttribute("tabindex")||a.content_elem.setAttribute("tabindex",0);var e=r(b.rows)?b.rows:a.fetchMarkup(),g={};b=a.scroll_elem.scrollTop;a.insertToDOM(e,g);a.scroll_elem.scrollTop=b;var k=!1,m=0,l=!1,t=function(){x&&(l||(a.content_elem.style.pointerEvents=
+"none"),l=!0,clearTimeout(m),m=setTimeout(function(){a.content_elem.style.pointerEvents="auto";l=!1},50));k!=(k=a.getClusterNum())&&a.insertToDOM(e,g);a.options.callbacks.scrollingProgress&&a.options.callbacks.scrollingProgress(a.getScrollProgress())},u=0,v=function(){clearTimeout(u);u=setTimeout(a.refresh,100)};q("scroll",a.scroll_elem,t);q("resize",window,v);a.destroy=function(b){n("scroll",a.scroll_elem,t);n("resize",window,v);a.html((b?a.generateEmptyRow():e).join(""))};a.refresh=function(b){(a.getRowsHeight(e)||
+b)&&a.update(e)};a.update=function(b){e=r(b)?b:[];b=a.scroll_elem.scrollTop;e.length*a.options.item_height<b&&(k=a.scroll_elem.scrollTop=0);a.insertToDOM(e,g);a.scroll_elem.scrollTop=b};a.clear=function(){a.update([])};a.getRowsAmount=function(){return e.length};a.getScrollProgress=function(){return this.options.scroll_top/(e.length*this.options.item_height)*100||0};var w=function(b,c){var d=r(c)?c:[];d.length&&(e="append"==b?e.concat(d):d.concat(e),a.insertToDOM(e,g))};a.append=function(a){w("append",
+a)};a.prepend=function(a){w("prepend",a)}};p.prototype={constructor:p,fetchMarkup:function(){for(var b=[],a=this.getChildNodes(this.content_elem);a.length;)b.push(a.shift().outerHTML);return b},exploreEnvironment:function(b,a){var c=this.options;c.content_tag=this.content_elem.tagName.toLowerCase();b.length&&(l&&9>=l&&!c.tag&&(c.tag=b[0].match(/<([^>\s/]*)/)[1].toLowerCase()),1>=this.content_elem.children.length&&(a.data=this.html(b[0]+b[0]+b[0])),c.tag||(c.tag=this.content_elem.children[0].tagName.toLowerCase()),
+this.getRowsHeight(b))},getRowsHeight:function(b){var a=this.options,c=a.item_height;a.cluster_height=0;if(b.length&&(b=this.content_elem.children,b.length)){var d=b[Math.floor(b.length/2)];a.item_height=d.offsetHeight;"tr"==a.tag&&"collapse"!=m("borderCollapse",this.content_elem)&&(a.item_height+=parseInt(m("borderSpacing",this.content_elem),10)||0);"tr"!=a.tag&&(b=parseInt(m("marginTop",d),10)||0,d=parseInt(m("marginBottom",d),10)||0,a.item_height+=Math.max(b,d));a.block_height=a.item_height*a.rows_in_block;
+a.rows_in_cluster=a.blocks_in_cluster*a.rows_in_block;a.cluster_height=a.blocks_in_cluster*a.block_height;return c!=a.item_height}},getClusterNum:function(){this.options.scroll_top=this.scroll_elem.scrollTop;return Math.floor(this.options.scroll_top/(this.options.cluster_height-this.options.block_height))||0},generateEmptyRow:function(){var b=this.options;if(!b.tag||!b.show_no_data_row)return[];var a=document.createElement(b.tag),c=document.createTextNode(b.no_data_text);a.className=b.no_data_class;
+if("tr"==b.tag){var d=document.createElement("td");d.colSpan=100;d.appendChild(c)}a.appendChild(d||c);return[a.outerHTML]},generate:function(b,a){var c=this.options,d=b.length;if(d<c.rows_in_block)return{top_offset:0,bottom_offset:0,rows_above:0,rows:d?b:this.generateEmptyRow()};var f=Math.max((c.rows_in_cluster-c.rows_in_block)*a,0),h=f+c.rows_in_cluster,e=Math.max(f*c.item_height,0);c=Math.max((d-h)*c.item_height,0);d=[];var g=f;for(1>e&&g++;f<h;f++)b[f]&&d.push(b[f]);return{top_offset:e,bottom_offset:c,
+rows_above:g,rows:d}},renderExtraTag:function(b,a){var c=document.createElement(this.options.tag);c.className=["clusterize-extra-row","clusterize-"+b].join(" ");a&&(c.style.height=a+"px");return c.outerHTML},insertToDOM:function(b,a){this.options.cluster_height||this.exploreEnvironment(b,a);var c=this.generate(b,this.getClusterNum()),d=c.rows.join(""),f=this.checkChanges("data",d,a),h=this.checkChanges("top",c.top_offset,a),e=this.checkChanges("bottom",c.bottom_offset,a),g=this.options.callbacks,
+k=[];f||h?(c.top_offset&&(this.options.keep_parity&&k.push(this.renderExtraTag("keep-parity")),k.push(this.renderExtraTag("top-space",c.top_offset))),k.push(d),c.bottom_offset&&k.push(this.renderExtraTag("bottom-space",c.bottom_offset)),g.clusterWillChange&&g.clusterWillChange(),this.html(k.join("")),"ol"==this.options.content_tag&&this.content_elem.setAttribute("start",c.rows_above),this.content_elem.style["counter-increment"]="clusterize-counter "+(c.rows_above-1),g.clusterChanged&&g.clusterChanged()):
+e&&(this.content_elem.lastChild.style.height=c.bottom_offset+"px")},html:function(b){var a=this.content_elem;if(l&&9>=l&&"tr"==this.options.tag){var c=document.createElement("div");for(c.innerHTML="<table><tbody>"+b+"</tbody></table>";b=a.lastChild;)a.removeChild(b);for(c=this.getChildNodes(c.firstChild.firstChild);c.length;)a.appendChild(c.shift())}else a.innerHTML=b},getChildNodes:function(b){b=b.children;for(var a=[],c=0,d=b.length;c<d;c++)a.push(b[c]);return a},checkChanges:function(b,a,c){var d=
+a!=c[b];c[b]=a;return d}};return p});
\ No newline at end of file
index 97f74f1ad1ccb86376c7dcacebe13be37bbf2cb5..7f560357c4bc2209be2ee8a89303dbcb1adad136 100644 (file)
@@ -348,17 +348,18 @@ body
     padding 0
     list-style none
 
-    li
-      span
-        display inline-block
-        min-width 3em
-        margin-right 1em
-        padding 0 0.25em
-        font-family courier
-        color #e5aa3d
+  .gcode ul
+    li:nth-child(even)
+      background-color #fafafa
+
+    li.highlight
+      background-color #eaeaea
 
-      &.highlight
-        background-color #eaeaea
+    span.gcode-line
+      display inline-block
+      padding 0 0.25em
+      color #e5aa3d
+      min-width 4em
 
   .history ul li
     cursor pointer