Skip to content

Loading…

Touch scrolling #143

Open
wants to merge 7 commits into from

1 participant

@kriszyp

No description provided.

kriszyp added some commits
@kriszyp kriszyp Add support for leveraging accelerated transforms on iOS <= 4
Add support for using native touch scrolling on iOS >=5 and Android >=4 (monitoring touch to fire pseudo scroll events for OnDemandList)
(Still uses plain scrollLeft/Top on older android, transforms don't seem to help)
Fix to use constant friction deceleration
Other glide fixes
Separate out dojox mobile test page, and miminal mobile test page
efc7d83
@kriszyp kriszyp Start on scrollbar c67a886
@kriszyp kriszyp Added touch-style scrollbars, improved accuracy of gliding e71f516
@kriszyp kriszyp Comment the code more, and lower the timer resolution now that we pro…
…perly compensate for delayed timer turns
e711e3c
@kriszyp kriszyp Properly handle intermediate scroll events on ios 5 and properly skip…
… nodes
e0a1876
@kriszyp kriszyp Merge git://github.com/SitePen/dgrid into touch-scrolling
Conflicts:
	OnDemandList.js
6113cac
@kriszyp kriszyp Fix event test so it doesn't break without an event parameter 13c4df8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Apr 9, 2012
  1. @kriszyp

    Add support for leveraging accelerated transforms on iOS <= 4

    kriszyp committed
    Add support for using native touch scrolling on iOS >=5 and Android >=4 (monitoring touch to fire pseudo scroll events for OnDemandList)
    (Still uses plain scrollLeft/Top on older android, transforms don't seem to help)
    Fix to use constant friction deceleration
    Other glide fixes
    Separate out dojox mobile test page, and miminal mobile test page
Commits on Apr 10, 2012
  1. @kriszyp

    Start on scrollbar

    kriszyp committed
Commits on Apr 11, 2012
  1. @kriszyp
  2. @kriszyp

    Comment the code more, and lower the timer resolution now that we pro…

    kriszyp committed
    …perly compensate for delayed timer turns
Commits on Apr 17, 2012
  1. @kriszyp
  2. @kriszyp

    Merge git://github.com/SitePen/dgrid into touch-scrolling

    kriszyp committed
    Conflicts:
    	OnDemandList.js
Commits on May 27, 2012
  1. @kriszyp
This page is out of date. Refresh to see the latest.
Showing with 275 additions and 95 deletions.
  1. +5 −6 OnDemandList.js
  2. +145 −39 TouchScroll.js
  3. +22 −1 css/dgrid.css
  4. +69 −0 test/dojox_mobile_grid.html
  5. +34 −49 test/mobile_grid.html
View
11 OnDemandList.js
@@ -44,8 +44,7 @@ return declare([List, _StoreMixin], {
this.inherited(arguments);
var self = this;
// check visibility on scroll events
- listen(this.bodyNode, "scroll",
- miscUtil.throttleDelayed(function(event){ self._processScroll(event); },
+ listen(this.bodyNode, "scroll", miscUtil.throttleDelayed(function(event){ self._processScroll(event); },
null, this.pagingDelay));
},
@@ -132,7 +131,7 @@ return declare([List, _StoreMixin], {
// if total is 0, IE quirks mode can't handle 0px height for some reason, I don't know why, but we are setting display: none for now
preloadNode.style.display = "none";
}
- self._processScroll(); // recheck the scroll position in case the query didn't fill the screen
+ self._processScroll({}); // recheck the scroll position in case the query didn't fill the screen
// can remove the loading node now
return trs;
});
@@ -163,14 +162,14 @@ return declare([List, _StoreMixin], {
},
lastScrollTop: 0,
- _processScroll: function(){
+ _processScroll: function(event){
// summary:
- // Checks to make sure that everything in the viewable area has been
+ // Checks to make sure that everything in te viewable area has been
// downloaded, and triggering a request for the necessary data when needed.
var grid = this,
scrollNode = grid.bodyNode,
transform = grid.contentNode.style.webkitTransform,
- visibleTop = scrollNode.scrollTop + (transform ? -transform.match(/translate[\w]*\(.*?,(.*?)px/)[1] : 0),
+ visibleTop = event && event.pseudoTouch ? scrollNode.instantScrollTop : scrollNode.scrollTop,
visibleBottom = scrollNode.offsetHeight + visibleTop,
priorPreload, preloadNode, preload = grid.preload,
lastScrollTop = grid.lastScrollTop,
View
184 TouchScroll.js
@@ -1,12 +1,16 @@
-define(["dojo/_base/declare", "dojo/on"],
-function(declare, on){
+define(["dojo/_base/declare", "dojo/on", "dojo/has", "put-selector/put"],
+function(declare, on, has, put){
+ var userAgent = navigator.userAgent;
+ // have to do some sniffing to guess if it has native overflow touch scrolling and accelerated transforms
+ has.add("touch-scrolling", document.documentElement.style.WebkitOverflowScrolling !== undefined || parseFloat(userAgent.split("Android ")[1]) >= 4);
+ has.add("accelerated-transform", !!userAgent.match(/like Mac/));
var
bodyTouchListener, // stores handle to body touch handler once connected
- timerRes = 15, // ms between drag velocity measurements and animation "ticks"
+ timerRes = 10, // ms between drag velocity measurements and animation "ticks"
touches = 0, // records number of touches on document
current = {}, // records info for widget currently being scrolled
glide = {}, // records info for widgets that are in "gliding" state
- glideThreshold = 1; // speed (in px) below which to stop glide
+ glideThreshold = 0.021; // speed (in px/ms) below which to stop glide
function updatetouchcount(evt){
touches = evt.touches.length;
@@ -22,17 +26,39 @@ function(declare, on){
clearTimeout(g.timer);
delete glide[id];
}
-
// check "global" touches count (which hasn't counted this touch yet)
if(touches > 0){ return; } // ignore multitouch gestures
-
+ if(has("touch-scrolling")){
+ // reset these prior to measurements
+ this.instantScrollLeft = 0;
+ this.instantScrollTop = 0;
+ }else{
+ // if the scrolling height and width is bigger than the area, than we add scrollbars in each direction
+ if(this.scrollHeight > this.offsetHeight){
+ var scrollbarYNode = this.scrollbarYNode;
+ if(!scrollbarYNode){
+ scrollbarYNode = this.scrollbarYNode = put(this.parentNode, "div.dgrid-touch-scrollbar-y");
+ scrollbarYNode.style.height = this.offsetHeight * this.offsetHeight / this.scrollHeight + "px";
+ scrollbarYNode.style.top = this.offsetTop + "px";
+ }
+ }
+ if(this.scrollWidth > this.offsetWidth){
+ var scrollbarXNode = this.scrollbarXNode;
+ if(!scrollbarXNode){
+ scrollbarXNode = this.scrollbarXNode = put(this.parentNode, "div.dgrid-touch-scrollbar-x");
+ scrollbarXNode.style.width = this.offsetWidth * this.offsetWidth / this.scrollWidth + "px";
+ scrollbarXNode.style.left = this.offsetLeft + "px";
+ }
+ }
+ // remove the fade class if we are reusing the scrollbar
+ put(this.parentNode, '!dgrid-touch-scrollbar-fade');
+ }
t = evt.touches[0];
current = {
widget: evt.widget,
node: this,
- startX: this.scrollLeft + t.pageX,
- startY: this.scrollTop + t.pageY,
- timer: setTimeout(calcTick, timerRes)
+ startX: (this.instantScrollLeft || this.scrollLeft) + t.pageX,
+ startY: (this.instantScrollTop || this.scrollTop) + t.pageY
};
}
function ontouchmove(evt){
@@ -41,79 +67,156 @@ function(declare, on){
t = evt.touches[0];
// snuff event and scroll the area
- evt.preventDefault();
- evt.stopPropagation();
- this.scrollLeft = current.startX - t.pageX;
- this.scrollTop = current.startY - t.pageY;
+ if(!has("touch-scrolling")){
+ evt.preventDefault();
+ evt.stopPropagation();
+ }
+
+ scroll(this, current.startX - t.pageX, current.startY - t.pageY);
+ calcVelocity();
}
function ontouchend(evt){
if(touches != 1 || !current){ return; }
- current.timer && clearTimeout(current.timer);
startGlide(current);
current = null;
}
-
+ function scroll(node, x, y){
+ // do the actual scrolling
+ var hasTouchScrolling = has("touch-scrolling");
+ x = Math.min(Math.max(0.01, x), node.scrollWidth - node.offsetWidth);
+ y = Math.min(Math.max(0.01, y), node.scrollHeight - node.offsetHeight);
+ if(!hasTouchScrolling && has("accelerated-transform")){
+ // we have hardward acceleration of transforms, so we will do the fast scrolling
+ // by setting the transform style with a translate3d
+ var transformNode = node.firstChild;
+ var lastScrollLeft = node.scrollLeft;
+ var lastScrollTop = node.scrollTop;
+ // store the current scroll position
+ node.instantScrollLeft = x;
+ node.instantScrollTop = y;
+ // set the style transform
+ transformNode.style.WebkitTransform = "translate3d(" + (node.scrollLeft - x) + "px,"
+ + (node.scrollTop - y) + "px,0)";
+ // now every half a second actually update the scroll position so that the scroll
+ // monitors (like OnDemandList) receive events and scroll positions to work with
+ if(!node._scrollWaiting){
+ node._scrollWaiting = true;
+ setTimeout(function(){
+ node._scrollWaiting = false;
+ // reset the transform since we are updating the actual scroll position
+ transformNode.style.WebkitTransform = "translate3d(0,0,0)";
+ // get the latest effective scroll position
+ node.scrollLeft = node.instantScrollLeft;
+ node.scrollTop = node.instantScrollTop;
+ // reset these so they aren't used anymore
+ node.instantScrollLeft = 0;
+ node.instantScrollTop = 0;
+ }, 500);
+ }
+ }else{
+ // update scroll position immediately (note we may be using browser's touch scroll
+ var scrollPrefix = hasTouchScrolling ? "instantScroll" : "scroll";
+ node[scrollPrefix + "Left"] = x;
+ node[scrollPrefix + "Top"] = y;
+
+ if(hasTouchScrolling){
+ // if we are using browser's touch scroll, we fire our own scroll events
+ on.emit(node, "scroll", {
+ pseudoTouch: true
+ });
+ }
+ }
+ if(!hasTouchScrolling){
+ // move the scrollbar
+ var scrollbarXNode = node.scrollbarXNode;
+ var scrollbarYNode = node.scrollbarYNode;
+ scrollbarXNode && (scrollbarXNode.style.WebkitTransform = "translate3d(" + (x * node.offsetWidth / node.scrollWidth) + "px,0,0)");
+ scrollbarYNode && (scrollbarYNode.style.WebkitTransform = "translate3d(0," + (y * node.offsetHeight / node.scrollHeight) + "px,0)");
+ }
+ }
// glide-related functions
- function calcTick(){
+ function calcVelocity(){
// Calculates current speed of touch drag
- var node, x, y;
+ var node, x, y, now;
if(!current){ return; } // no currently-scrolling widget; abort
node = current.node;
- x = node.scrollLeft;
- y = node.scrollTop;
+ x = node.instantScrollLeft || node.scrollLeft;
+ y = node.instantScrollTop || node.scrollTop;
+ now = new Date().getTime();
if("prevX" in current){
// calculate velocity using previous reference point
- current.velX = x - current.prevX;
- current.velY = y - current.prevY;
+ var duration = now - current.prevTime;
+ current.velX = (x - current.prevX) / duration;
+ current.velY = (y - current.prevY) / duration;
+
+ }
+ if(!(current.prevTime - now > -150)){ // make sure it is far enough back that we can get a good estimate
+ // set previous reference point for next iteration
+ current.prevX = x;
+ current.prevY = y;
+ current.prevTime = now;
}
- // set previous reference point for next iteration
- current.prevX = x;
- current.prevY = y;
- current.timer = setTimeout(calcTick, timerRes);
}
-
+ var lastGlideTime;
function startGlide(info){
// starts glide operation when drag ends
var id = info.widget.id, g;
- if(!info.velX && !info.velY){ return; } // no glide to perform
+ if(!info.velX && !info.velY){
+ fadeScrollBars(info.node);
+ return;
+ } // no glide to perform
g = glide[id] = info; // reuse object for widget/node/vel properties
g.calcFunc = function(){ calcGlide(id); }
+ lastGlideTime = new Date().getTime();
g.timer = setTimeout(g.calcFunc, timerRes);
}
function calcGlide(id){
// performs glide and decelerates according to widget's glideDecel method
var g = glide[id], x, y, node, widget,
- vx, vy, nvx, nvy; // old and new velocities
-
+ vx, vy, nvx, nvy, // old and new velocities
+ now = new Date().getTime(),
+ sinceLastGlide = now - lastGlideTime;
if(!g){ return; }
-
node = g.node;
widget = g.widget;
- x = node.scrollLeft;
- y = node.scrollTop;
+ // we use instantScroll... so that OnDemandList has something to pull from to get the current value (needed for ios5 with touch scrolling)
+ x = node.instantScrollLeft || node.scrollLeft;
+ y = node.instantScrollTop || node.scrollTop;
+ // note that velocity is measured in pixels per millisecond
vx = g.velX;
vy = g.velY;
- nvx = widget.glideDecel(vx);
- nvy = widget.glideDecel(vy);
+ nvx = widget.glideDecel(vx, sinceLastGlide);
+ nvy = widget.glideDecel(vy, sinceLastGlide);
+ var continueGlide;
if(Math.abs(nvx) >= glideThreshold || Math.abs(nvy) >= glideThreshold){
// still above stop threshold; update scroll positions
- node.scrollLeft += nvx;
- node.scrollTop += nvy;
- if(node.scrollLeft != x || node.scrollTop != y){
+ scroll(node, x + nvx * sinceLastGlide, y + nvy * sinceLastGlide); // for each dimension multiply the velocity (px/ms) by the ms elapsed
+ if((node.instantScrollLeft || node.scrollLeft) != x || (node.instantScrollTop || node.scrollTop) != y){
// still scrollable; update velocities and schedule next tick
+ continueGlide = true;
g.velX = nvx;
g.velY = nvy;
g.timer = setTimeout(g.calcFunc, timerRes);
}
}
+ if(!continueGlide){
+ fadeScrollBars(node);
+
+ }
+ lastGlideTime = now;
+ }
+ function fadeScrollBars(node){
+ // add the fade class so that scrollbar fades to transparent
+ put(node.parentNode, '.dgrid-touch-scrollbar-fade');
}
return declare([], {
+ pagingDelay: 500,
startup: function(){
var node = this.touchNode || this.containerNode || this.domNode,
widget = this;
@@ -131,12 +234,15 @@ function(declare, on){
"touchstart,touchend,touchcancel", updatetouchcount);
}
},
- glideDecel: function(n){
+ // friction: Float
+ // This is the friction deceleration measured in pixels/milliseconds^2
+ friction: 0.0006,
+ glideDecel: function(n, sinceLastGlide){
// summary:
// Deceleration algorithm. Given a number representing velocity,
// returns a new velocity to impose for the next "tick".
// (Don't forget that velocity can be positive or negative!)
- return n * 0.9; // Number
+ return n + (n > 0 ? -sinceLastGlide : sinceLastGlide) * this.friction; // Number
}
});
});
View
23 css/dgrid.css
@@ -79,19 +79,40 @@ html.has-quirks .dgrid-header-hidden .dgrid-cell {
}
.dgrid-content {
- position: relative;
+ /*position: relative;*/
height: 99%;
}
.dgrid-scroller {
overflow-x: auto;
overflow-y: scroll;
+ -webkit-overflow-scrolling: touch;
position: absolute;
top: 0px;
margin-top: 25px; /* this will be adjusted programmatically to fit below the header*/
bottom: 0px;
width: 100%;
}
+.dgrid-touch-scrollbar-y, .dgrid-touch-scrollbar-x {
+ position: absolute;
+ background-color: rgba(88,88,88,0.97);
+ opacity: 0.7;
+ border: 1px solid rgba(88,88,88,1);
+ border-radius: 3px;
+ -webkit-box-shadow: 0 0 1px rgba(88,88,88,0.4); /* the border's aren't anti-aliased on android, so this smooths it out a bit*/
+}
+.dgrid-touch-scrollbar-y {
+ right: 1px;
+ width: 3px;
+}
+.dgrid-touch-scrollbar-x {
+ bottom: 1px;
+ height: 3px;
+}
+.dgrid-touch-scrollbar-fade .dgrid-touch-scrollbar-y, .dgrid-touch-scrollbar-fade .dgrid-touch-scrollbar-x {
+ -webkit-transition: opacity 0.3s ease-out 0.3s;
+ opacity: 0;
+}
.dgrid-loading {
position: relative;
View
69 test/dojox_mobile_grid.html
@@ -0,0 +1,69 @@
+
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
+<html>
+ <head>
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no"/>
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <title>Mobile dgrid test</title>
+ <link rel="stylesheet" href="../../dojo/resources/dojo.css">
+ <link rel="stylesheet" href="../../dojox/mobile/themes/iphone/base.css">
+ <link rel="stylesheet" href="../css/dgrid.css">
+
+ <script language="JavaScript" type="text/javascript">
+ var dojoConfig = { deps: [ "dojox/mobile", "dojox/mobile/parser", "dojox/mobile/compat" ], parseOnLoad: true, async: true }
+ </script>
+ <script type="text/javascript" src="../../dojo/dojo.js"></script>
+ <script type="text/javascript">
+ require(["dojo/on", "dgrid/OnDemandGrid", "dgrid/extensions/ColumnResizer", "dojo/_base/declare", "dgrid/test/data/base", "dojo/domReady!"],
+ function(on, Grid, ColumnResizer, declare, testStore){
+ window.grid = new (declare([Grid, ColumnResizer]))({
+ store: testStore,
+ columns: { // you can declare columns as an object hash (key translates to field)
+ col1: "Status",
+ col4: "Expired",
+ col5: {label: "Weight"}
+ }
+ }, "grid");
+ });
+ </script>
+ </head>
+ <body class="claro" style="visibility:hidden;">
+ <div id="view1" data-dojo-type="dojox.mobile.View" selected="true">
+ <div id="view11" data-dojo-type="dojox.mobile.View" selected="true">
+ <h1 data-dojo-type="dojox.mobile.Heading">View dgrid</h1>
+ <h2 data-dojo-type="dojox.mobile.RoundRectCategory">dgrid Mobile Test</h2>
+
+ <ul data-dojo-type="dojox.mobile.RoundRectList">
+ <li data-dojo-type="dojox.mobile.ListItem" icon="../../dojox/mobile/tests/images/i-icon-1.png" moveTo="view12" transition="slide">
+ View dgrid
+ </li>
+ <li data-dojo-type="dojox.mobile.ListItem" icon="../../dojox/mobile/tests/images/i-icon-3.png" moveTo="view2" transition="slide">
+ View 2
+ </li>
+ </ul>
+ </div>
+
+ <div id="view12" data-dojo-type="dojox.mobile.View">
+ <h1 data-dojo-type="dojox.mobile.Heading">View 1-2</h1>
+ <ul data-dojo-type="dojox.mobile.RoundRectList">
+ <li data-dojo-type="dojox.mobile.ListItem" icon="../../dojox/mobile/tests/images/i-icon-2.png" moveTo="view11" transition="slide" transitionDir="-1">
+ Home
+ </li>
+ <li data-dojo-type="dojox.mobile.ListItem" icon="../../dojox/mobile/tests/images/i-icon-3.png" moveTo="view2" transition="slide">
+ View 2
+ </li>
+ </ul>
+ <div id="grid"></div>
+ </div>
+ </div>
+
+ <div id="view2" data-dojo-type="dojox.mobile.View">
+ <h1 data-dojo-type="dojox.mobile.Heading">View 2</h1>
+ <ul data-dojo-type="dojox.mobile.RoundRectList">
+ <li data-dojo-type="dojox.mobile.ListItem" icon="../../dojox/mobile/tests/images/i-icon-1.png" moveTo="view1" transition="slide" transitionDir="-1">
+ Back
+ </li>
+ </ul>
+ </div>
+ </body>
+</html>
View
83 test/mobile_grid.html
@@ -6,20 +6,39 @@
<meta name="apple-mobile-web-app-capable" content="yes" />
<title>Mobile dgrid test</title>
<link rel="stylesheet" href="../../dojo/resources/dojo.css">
- <link rel="stylesheet" href="../../dojox/mobile/themes/iphone/base.css">
- <link rel="stylesheet" href="../css/skins/claro.css">
<link rel="stylesheet" href="../css/dgrid.css">
-
- <script language="JavaScript" type="text/javascript">
- var dojoConfig = { deps: [ "dojox/mobile", "dojox/mobile/parser", "dojox/mobile/compat" ], parseOnLoad: true, async: true }
- </script>
- <script type="text/javascript" src="../../dojo/dojo.js"></script>
+ <link rel="stylesheet" href="../css/skins/claro.css">
+ <style>
+ .header, #grid, .footer {
+ margin-top: 0px;
+ left: 0px;
+ right: 0px;
+ position: absolute;
+ }
+ .header {
+ height: 50px;
+ }
+ .footer {
+ bottom: 0px;
+ height: 30px;
+ }
+ .field-col1, .field-col4, .field-col5 {
+/* width: 400px;*/
+ }
+ #grid {
+ height: auto;
+ bottom: 30px;
+ top: 50px;
+ }
+ </style>
+ <script type="text/javascript" src="../../dojo/dojo.js"
+ data-dojo-config="async: true"></script>
<script type="text/javascript">
- require(["dojo/on", "dgrid/OnDemandGrid", "dgrid/extensions/ColumnResizer", "dojo/_base/declare", "dgrid/test/data/base", "dojo/domReady!"],
- function(on, Grid, ColumnResizer, declare, testStore){
- window.grid = new (declare([Grid, ColumnResizer]))({
+ require(["dojo/on", "dgrid/OnDemandGrid", "dgrid/test/data/base", "dojo/domReady!"],
+ function(on, Grid, testStore){
+ window.grid = new Grid({
store: testStore,
- columns: { // you can declare columns as an object hash (key translates to field)
+ columns: {
col1: "Status",
col4: "Expired",
col5: {label: "Weight"}
@@ -28,43 +47,9 @@
});
</script>
</head>
- <body class="claro" style="visibility:hidden;">
- <div id="view1" data-dojo-type="dojox.mobile.View" selected="true">
- <div id="view11" data-dojo-type="dojox.mobile.View" selected="true">
- <h1 data-dojo-type="dojox.mobile.Heading">View dgrid</h1>
- <h2 data-dojo-type="dojox.mobile.RoundRectCategory">dgrid Mobile Test</h2>
-
- <ul data-dojo-type="dojox.mobile.RoundRectList">
- <li data-dojo-type="dojox.mobile.ListItem" icon="../../dojox/mobile/tests/images/i-icon-1.png" moveTo="view12" transition="slide">
- View dgrid
- </li>
- <li data-dojo-type="dojox.mobile.ListItem" icon="../../dojox/mobile/tests/images/i-icon-3.png" moveTo="view2" transition="slide">
- View 2
- </li>
- </ul>
- </div>
-
- <div id="view12" data-dojo-type="dojox.mobile.View">
- <h1 data-dojo-type="dojox.mobile.Heading">View 1-2</h1>
- <ul data-dojo-type="dojox.mobile.RoundRectList">
- <li data-dojo-type="dojox.mobile.ListItem" icon="../../dojox/mobile/tests/images/i-icon-2.png" moveTo="view11" transition="slide" transitionDir="-1">
- Home
- </li>
- <li data-dojo-type="dojox.mobile.ListItem" icon="../../dojox/mobile/tests/images/i-icon-3.png" moveTo="view2" transition="slide">
- View 2
- </li>
- </ul>
- <div id="grid"></div>
- </div>
- </div>
-
- <div id="view2" data-dojo-type="dojox.mobile.View">
- <h1 data-dojo-type="dojox.mobile.Heading">View 2</h1>
- <ul data-dojo-type="dojox.mobile.RoundRectList">
- <li data-dojo-type="dojox.mobile.ListItem" icon="../../dojox/mobile/tests/images/i-icon-1.png" moveTo="view1" transition="slide" transitionDir="-1">
- Back
- </li>
- </ul>
- </div>
+ <body class="claro">
+ <h1 class="header ui-widget-header">Header (this is a minimal mobile dgrid with a header and footer)</h1>
+ <div id="grid"></div>
+ <div class="footer ui-widget-header">Made with dgrid</div>
</body>
</html>
Something went wrong with that request. Please try again.