Skip to content

Instantly share code, notes, and snippets.

@bwindels
Last active March 18, 2019 10:18
Show Gist options
  • Save bwindels/b3a3ba3fa3792f23962d894866fa7b0f to your computer and use it in GitHub Desktop.
Save bwindels/b3a3ba3fa3792f23962d894866fa7b0f to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style type="text/css">
body {
padding: 10px;
margin: 0;
display: flex;<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style type="text/css">
body {
padding: 10px;
margin: 0;
display: flex;
flex-direction: column;
height: 100vh;
box-sizing: border-box;
}
@keyframes width-animation {
from { margin-right: 0; }
to { margin-right: 75%; }
}
@keyframes rotate-animation {
from { transform: rotate(0); }
to { transform: rotate(360deg); }
}
li.loading {
background-color: white !important;
display: flex;
--spinner-bg-color: white;
justify-content: center;
}
.spinner {
background: -webkit-linear-gradient(left top, black 0%, var(--spinner-bg-color) 80%, var(--spinner-bg-color) 100%);
border-radius: 30px;
padding: 6px;
width: 40px;
height: 40px;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-play-state: running;
animation-duration: 2s;
animation-name: rotate-animation;
}
.spinner div {
height: 100%;
width: 100%;
border-radius: 30px;
background-color: var(--spinner-bg-color);
}
#timeline {
position: relative;
flex: 1;
overflow-y: auto;
border: 1px black solid;
/*
default is auto which keeps a node in view just like we want
but for now it's only supported in chrome and seems to break on
macOs while scrolling. So let's turn it off and do the position
restoring ourselves.
*/
overflow-anchor: none;
}
#timeline.stickToBottom #tiles li:last-child {
background-color: green;
color: white;
}
#tiles {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
margin: 0;
padding: 10px;
list-style: none;
overflow-y: hidden;
}
#tiles li {
padding: 10px;
margin: 20px 0;
background-color: lightgrey;
}
#tiles li.pinned {
background-color: red;
color: white;
}
#settings {
z-index: 1;
position: fixed;
top: 10px;
left: 10px;
background-color: white;
border: 1px solid green;
padding: 10px;
}
#settings p {
margin: 5px;
}
.unfill {
position: relative;
background-color: lightgreen;
}
#unfill-percent {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 0%;
background-color: darkgreen;
color: white;
white-space: nowrap;
overflow: hidden;
}
</style>
</head>
<body>
<div id="timeline" class="stickToBottom">
<div id="settings">
<p><input type="checkbox" id="append-check" autocomplete="off" checked><label id="status" for="append-check">Append nodes</label></p>
<p><input type="checkbox" id="loadattop-check" autocomplete="off" checked><label id="status" for="loadattop-check">Insert more when at top</label></p>
<p><input type="checkbox" id="resize-check" autocomplete="off" checked><label for="resize-check">Resize a node</label></p>
<div class="unfill">
<div id="unfill-percent">bottom unfill distance</div>
<div class="unfill-label">bottom unfill distance</div>
</div>
</div>
<ol id="tiles">
<li class="loading"><div class="spinner"><div></div></div></li>
<li>First post!</li>
</ol>
</div>
<script type="text/javascript">
const timeline = document.getElementById("timeline");
const tiles = document.getElementById("tiles");
const unfillPercent = document.getElementById("unfill-percent");
const loadAtTopCheck = document.getElementById("loadattop-check");
const PAGE_SIZE = 200;
let pages = Math.ceil(timeline.clientHeight / PAGE_SIZE);
let bottomGrowth = 0;
function viewportBottom() {
// this is measured from top, so subtract from total height
return timeline.scrollHeight - (timeline.scrollTop + timeline.clientHeight);
}
function topFromBottom(node) {
return tiles.clientHeight - node.offsetTop;
}
function findLastNodeInViewport() {
const vpBottom = viewportBottom();
// do binary search here
for (var i = tiles.children.length - 1; i >= 0; i--) {
const node = tiles.children[i];
if ((tiles.clientHeight - node.offsetTop) > vpBottom) {
return node;
}
}
return tiles.firstElementChild.nextElementSibling;
}
tiles.style.height = `${getContainerHeight()}px`;
timeline.scrollTop = timeline.scrollHeight;
let pinnedNode = tiles.lastElementChild;
let pinnedTop = topFromBottom(pinnedNode);
let stickToBottom = true;
let prevScrollTop = timeline.scrollTop;
let scrollInProgress = null;
let lastScrollTimestamp = 0;
timeline.addEventListener("scroll", () => {
pinnedNode.classList.remove("pinned");
const atBottom = isAtBottom();
if (atBottom !== stickToBottom) {
if (atBottom) {
timeline.classList.add("stickToBottom");
} else {
timeline.classList.remove("stickToBottom");
}
stickToBottom = atBottom;
}
if (!atBottom) {
// const contentHeight = getContentHeight();
// const minScrollTop = timeline.scrollHeight - contentHeight;
// if (timeline.scrollTop < minScrollTop) {
// setTimeout(() => {
// tiles.firstElementChild.scrollIntoView({block: "start", inline: "nearest", behaviour: "smooth"});
// }, 1);
// timeline.scrollTop = minScrollTop;
// }
// save bottom node position
pinnedNode = findLastNodeInViewport();
pinnedNode.classList.add("pinned");
pinnedTop = topFromBottom(pinnedNode);
console.log("onscroll: viewportBottom()", viewportBottom(), "pinnedTop", pinnedTop);
// scrolling up and almost at top
if (loadAtTopCheck.checked && timeline.scrollTop < 250 && prevScrollTop > timeline.scrollTop) {
if (!alreadyUpdating) {
alreadyUpdating = true;
loadAtTop();
updateHeight().then(() => {
alreadyUpdating = false;
});
}
}
// // scrolling down and one screen above
// else if (timeline.scrollTop > (PAGE_SIZE + 100) && prevScrollTop < timeline.scrollTop) {
// incrementTopPage(-1);
// }
// we will need to do proper top unfilling here as well
checkBottomUnfillState();
}
if (!scrollInProgress) {
const SCROLL_DEBOUNCE_INTERVAL = 100;
lastScrollTimestamp = Date.now();
scrollInProgress = new Promise(resolve => {
function timer() {
const now = Date.now();
const elapsed = now - lastScrollTimestamp;
if (elapsed >= SCROLL_DEBOUNCE_INTERVAL) {
resolve();
scrollInProgress = null;
} else {
setTimeout(timer, SCROLL_DEBOUNCE_INTERVAL - elapsed);
}
}
setTimeout(timer, SCROLL_DEBOUNCE_INTERVAL);
});
} else {
lastScrollTimestamp = Date.now();
}
prevScrollTop = timeline.scrollTop;
});
function isAtBottom() {
return timeline.scrollTop === timeline.scrollHeight - timeline.clientHeight;
}
function getContentHeight() {
const lastNode = tiles.lastElementChild;
// 30 is the 20px margin between items, and 10px padding of #tiles
return (lastNode.offsetTop + lastNode.clientHeight) - tiles.firstElementChild.offsetTop + (2 * 30);
}
function getContainerHeight() {
return bottomGrowth + (pages * PAGE_SIZE);
}
async function updateHeight() {
if (scrollInProgress) {
await scrollInProgress;
}
// paginate backwards
const currentHeight = getContainerHeight();
const contentHeight = getContentHeight();
const minHeight = timeline.clientHeight;
const height = Math.max(minHeight, contentHeight);
pages = Math.ceil(height / PAGE_SIZE);
bottomGrowth = 0;
const newHeight = getContainerHeight();
if (stickToBottom) {
tiles.style.height = `${newHeight}px`;
timeline.scrollTop = timeline.scrollHeight;
console.log("updateHeight to", newHeight);
} else {
const oldTop = pinnedNode.offsetTop;
tiles.style.height = `${newHeight}px`;
const newTop = pinnedNode.offsetTop;
const topDiff = newTop - oldTop;
timeline.scrollTop = timeline.scrollTop + topDiff;
console.log("updateHeight to", newHeight, topDiff);
}
// console.log("setting pages to", pages, "pages at the top, with heightDiff of", heightDiff, "changed scrollTop from, to", oldScrollTop, timeline.scrollTop);
}
let alreadyUpdating = false;
async function restorePinnedPosition() {
if (stickToBottom) {
timeline.scrollTop = timeline.scrollHeight;
} else {
const newPinnedTop = topFromBottom(pinnedNode);
// did the distance to the bottom change?
// compensate by making #tiles higher to push
// things back down by the same amount
if (newPinnedTop !== pinnedTop) {
const bottomDiff = newPinnedTop - pinnedTop;
bottomGrowth += bottomDiff;
pinnedTop = newPinnedTop;
// console.log("restoring position: bottomDiff", bottomDiff, "bottomGrowth", bottomGrowth);
tiles.style.height = `${getContainerHeight()}px`;
}
}
// for content added above the pinnedNode
// we need to set scrollTop to compensate
// the height change.
if (!alreadyUpdating) {
const heightDiff = getContentHeight() - getContainerHeight();
if (heightDiff > 0) {
alreadyUpdating = true;
await updateHeight();
alreadyUpdating = false;
}
}
}
/*
function setHeight() {
const contentHeight = getContentHeight();
const maxHeight = getContainerHeight();
const minHeight = timeline.clientHeight;
const height = Math.min(Math.max(minHeight, contentHeight), maxHeight);
console.log("setHeight", minHeight, contentHeight, maxHeight, height);
tiles.style.height = `${height}px`;
return height;
}
*/
let unfillHandle = null;
function checkBottomUnfillState() {
const UNFILL_TRESHHOLD = PAGE_SIZE * 11;
const UNFILL_AMOUNT = PAGE_SIZE * 10;
if (!unfillHandle) {
unfillHandle = setTimeout(() => {
if (pinnedTop > UNFILL_TRESHHOLD) {
const removedSpace = removeAtBottom(UNFILL_AMOUNT);
// don't break the resizing
resizeNode = tiles.lastElementChild;
console.log("unfilled ", removedSpace, " pixels at the bottom");
restorePinnedPosition();
}
const percent = Math.max(0, pinnedTop / UNFILL_TRESHHOLD);
unfillPercent.style.width = `${Math.ceil(percent * 100)}%`;
unfillHandle = null;
}, 1000);
}
}
function removeAtBottom(maxHeight) {
let removeFromNode = tiles.lastElementChild;
let prevNode = removeFromNode.previousElementSibling;
while(prevNode && topFromBottom(prevNode) < maxHeight && prevNode !== pinnedNode) {
removeFromNode = prevNode;
prevNode = removeFromNode.previousElementSibling;
}
const height = topFromBottom(removeFromNode);
while(removeFromNode) {
const tmp = removeFromNode.nextElementSibling;
removeFromNode.remove();
removeFromNode = tmp;
}
return height;
}
const WORDS = ["foo", "bar", "hippo", "giraffe", "rollercoaster", "💩", "🌍"];
let wordIdx = 0;
function createMessage() {
const li = document.createElement("li");
++wordIdx;
if (wordIdx >= WORDS.length) wordIdx = 0;
const message = (WORDS[wordIdx]+" ").repeat(Math.ceil(Math.random() * 100));
li.appendChild(document.createTextNode(message));
return li;
}
function append() {
tiles.appendChild(createMessage());
restorePinnedPosition();
}
let appendTimer = setInterval(append, 500);
document.getElementById("append-check").addEventListener("change", () => {
if (appendTimer) {
clearInterval(appendTimer);
appendTimer = null;
} else {
appendTimer = setInterval(append, 500);
}
});
async function loadAtTop() {
const frag = document.createDocumentFragment();
for (var i = 10 - 1; i >= 0; i--) {
frag.appendChild(createMessage());
}
tiles.insertBefore(frag, tiles.firstElementChild.nextElementSibling);
}
const SIZES = ["0 0 auto", "0 0 200px", "0 0 400px"];
let sizeIdx = 0;
let resizeNode = tiles.lastElementChild;
function resizeLast() {
++sizeIdx;
if (sizeIdx >= SIZES.length) {
sizeIdx = 0;
}
resizeNode.style.flex = SIZES[sizeIdx];
restorePinnedPosition();
}
let resizeTimer = setInterval(resizeLast, 1789);
document.getElementById("resize-check").addEventListener("change", () => {
if (resizeTimer) {
clearInterval(resizeTimer);
resizeTimer = null;
} else {
resizeTimer = setInterval(resizeLast, 1789);
}
});
window.addEventListener("resize", () => {
restorePinnedPosition();
});
</script>
</body>
</html>
flex-direction: column;
height: 100vh;
box-sizing: border-box;
}
@keyframes width-animation {
from { margin-right: 0; }
to { margin-right: 75%; }
}
@keyframes rotate-animation {
from { transform: rotate(0); }
to { transform: rotate(360deg); }
}
li.loading {
background-color: white !important;
display: flex;
--spinner-bg-color: white;
justify-content: center;
}
.spinner {
background: -webkit-linear-gradient(left top, black 0%, var(--spinner-bg-color) 80%, var(--spinner-bg-color) 100%);
border-radius: 30px;
padding: 6px;
width: 40px;
height: 40px;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-play-state: running;
animation-duration: 2s;
animation-name: rotate-animation;
}
.spinner div {
height: 100%;
width: 100%;
border-radius: 30px;
background-color: var(--spinner-bg-color);
}
#timeline {
position: relative;
flex: 1;
overflow-y: auto;
border: 1px black solid;
/*
default is auto which keeps a node in view just like we want
but for now it's only supported in chrome and seems to break on
macOs while scrolling. So let's turn it off and do the position
restoring ourselves.
*/
overflow-anchor: none;
}
#timeline.stickToBottom #tiles li:last-child {
background-color: green;
color: white;
}
#tiles {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
margin: 0;
padding: 10px;
list-style: none;
overflow-y: hidden;
}
#tiles li {
padding: 10px;
margin: 20px 0;
background-color: lightgrey;
}
#tiles li.pinned {
background-color: red;
color: white;
}
#settings {
z-index: 1;
position: fixed;
top: 10px;
left: 10px;
background-color: white;
border: 1px solid green;
padding: 10px;
}
#settings p {
margin: 5px;
}
.unfill {
position: relative;
background-color: lightgreen;
}
#unfill-percent {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 0%;
background-color: darkgreen;
color: white;
white-space: nowrap;
overflow: hidden;
}
</style>
</head>
<body>
<div id="timeline" class="stickToBottom">
<div id="settings">
<p><input type="checkbox" id="append-check" autocomplete="off" checked><label id="status" for="append-check">Append nodes</label></p>
<p><input type="checkbox" id="loadattop-check" autocomplete="off" checked><label id="status" for="loadattop-check">Insert more when at top</label></p>
<p><input type="checkbox" id="resize-check" autocomplete="off" checked><label for="resize-check">Resize a node</label></p>
<div class="unfill">
<div id="unfill-percent">bottom unfill distance</div>
<div class="unfill-label">bottom unfill distance</div>
</div>
</div>
<ol id="tiles">
<li class="loading"><div class="spinner"><div></div></div></li>
<li>First post!</li>
</ol>
</div>
<script type="text/javascript">
const timeline = document.getElementById("timeline");
const tiles = document.getElementById("tiles");
const unfillPercent = document.getElementById("unfill-percent");
const loadAtTopCheck = document.getElementById("loadattop-check");
const PAGE_SIZE = 200;
let pages = Math.ceil(timeline.clientHeight / PAGE_SIZE);
let bottomGrowth = 0;
function viewportBottom() {
// this is measured from top, so subtract from total height
return timeline.scrollHeight - (timeline.scrollTop + timeline.clientHeight);
}
function topFromBottom(node) {
return tiles.clientHeight - node.offsetTop;
}
function findLastNodeInViewport() {
const vpBottom = viewportBottom();
// do binary search here
for (var i = tiles.children.length - 1; i >= 0; i--) {
const node = tiles.children[i];
if ((tiles.clientHeight - node.offsetTop) > vpBottom) {
return node;
}
}
return tiles.firstElementChild.nextElementSibling;
}
tiles.style.height = `${getContainerHeight()}px`;
timeline.scrollTop = timeline.scrollHeight;
let pinnedNode = tiles.lastElementChild;
let pinnedTop = topFromBottom(pinnedNode);
let stickToBottom = true;
let prevScrollTop = timeline.scrollTop;
let scrollInProgress = null;
let lastScrollTimestamp = 0;
timeline.addEventListener("scroll", () => {
pinnedNode.classList.remove("pinned");
const atBottom = isAtBottom();
if (atBottom !== stickToBottom) {
if (atBottom) {
timeline.classList.add("stickToBottom");
} else {
timeline.classList.remove("stickToBottom");
}
stickToBottom = atBottom;
}
if (!atBottom) {
// const contentHeight = getContentHeight();
// const minScrollTop = timeline.scrollHeight - contentHeight;
// if (timeline.scrollTop < minScrollTop) {
// setTimeout(() => {
// tiles.firstElementChild.scrollIntoView({block: "start", inline: "nearest", behaviour: "smooth"});
// }, 1);
// timeline.scrollTop = minScrollTop;
// }
// save bottom node position
pinnedNode = findLastNodeInViewport();
pinnedNode.classList.add("pinned");
pinnedTop = topFromBottom(pinnedNode);
console.log("onscroll: viewportBottom()", viewportBottom(), "pinnedTop", pinnedTop);
// scrolling up and almost at top
if (loadAtTopCheck.checked && timeline.scrollTop < 250 && prevScrollTop > timeline.scrollTop) {
if (!alreadyUpdating) {
alreadyUpdating = true;
loadAtTop();
updateHeight().then(() => {
alreadyUpdating = false;
});
}
}
// // scrolling down and one screen above
// else if (timeline.scrollTop > (PAGE_SIZE + 100) && prevScrollTop < timeline.scrollTop) {
// incrementTopPage(-1);
// }
// we will need to do proper top unfilling here as well
checkBottomUnfillState();
}
if (!scrollInProgress) {
const SCROLL_DEBOUNCE_INTERVAL = 100;
lastScrollTimestamp = Date.now();
scrollInProgress = new Promise(resolve => {
function timer() {
const now = Date.now();
const elapsed = now - lastScrollTimestamp;
if (elapsed >= SCROLL_DEBOUNCE_INTERVAL) {
resolve();
scrollInProgress = null;
} else {
setTimeout(timer, SCROLL_DEBOUNCE_INTERVAL - elapsed);
}
}
setTimeout(timer, SCROLL_DEBOUNCE_INTERVAL);
});
} else {
lastScrollTimestamp = Date.now();
}
prevScrollTop = timeline.scrollTop;
});
function isAtBottom() {
return timeline.scrollTop === timeline.scrollHeight - timeline.clientHeight;
}
function getContentHeight() {
const lastNode = tiles.lastElementChild;
// 30 is the 20px margin between items, and 10px padding of #tiles
return (lastNode.offsetTop + lastNode.clientHeight) - tiles.firstElementChild.offsetTop + (2 * 30);
}
function getContainerHeight() {
return bottomGrowth + (pages * PAGE_SIZE);
}
async function updateHeight() {
if (scrollInProgress) {
await scrollInProgress;
}
// paginate backwards
const currentHeight = getContainerHeight();
const contentHeight = getContentHeight();
const minHeight = timeline.clientHeight;
const height = Math.max(minHeight, contentHeight);
pages = Math.ceil(height / PAGE_SIZE);
bottomGrowth = 0;
const newHeight = getContainerHeight();
if (stickToBottom) {
tiles.style.height = `${newHeight}px`;
timeline.scrollTop = timeline.scrollHeight;
console.log("updateHeight to", newHeight);
} else {
const oldTop = pinnedNode.offsetTop;
tiles.style.height = `${newHeight}px`;
const newTop = pinnedNode.offsetTop;
const topDiff = newTop - oldTop;
timeline.scrollTop = timeline.scrollTop + topDiff;
console.log("updateHeight to", newHeight, topDiff);
}
// console.log("setting pages to", pages, "pages at the top, with heightDiff of", heightDiff, "changed scrollTop from, to", oldScrollTop, timeline.scrollTop);
}
let alreadyUpdating = false;
async function restorePinnedPosition() {
if (stickToBottom) {
timeline.scrollTop = timeline.scrollHeight;
} else {
const newPinnedTop = topFromBottom(pinnedNode);
// did the distance to the bottom change?
// compensate by making #tiles higher to push
// things back down by the same amount
if (newPinnedTop !== pinnedTop) {
const bottomDiff = newPinnedTop - pinnedTop;
bottomGrowth += bottomDiff;
pinnedTop = newPinnedTop;
// console.log("restoring position: bottomDiff", bottomDiff, "bottomGrowth", bottomGrowth);
tiles.style.height = `${getContainerHeight()}px`;
}
}
// for content added above the pinnedNode
// we need to set scrollTop to compensate
// the height change.
if (!alreadyUpdating) {
const heightDiff = getContentHeight() - getContainerHeight();
if (heightDiff > 0) {
alreadyUpdating = true;
await updateHeight();
alreadyUpdating = false;
}
}
}
/*
function setHeight() {
const contentHeight = getContentHeight();
const maxHeight = getContainerHeight();
const minHeight = timeline.clientHeight;
const height = Math.min(Math.max(minHeight, contentHeight), maxHeight);
console.log("setHeight", minHeight, contentHeight, maxHeight, height);
tiles.style.height = `${height}px`;
return height;
}
*/
let unfillHandle = null;
function checkBottomUnfillState() {
const UNFILL_TRESHHOLD = PAGE_SIZE * 11;
const UNFILL_AMOUNT = PAGE_SIZE * 10;
if (!unfillHandle) {
unfillHandle = setTimeout(() => {
if (pinnedTop > UNFILL_TRESHHOLD) {
const removedSpace = removeAtBottom(UNFILL_AMOUNT);
// don't break the resizing
resizeNode = tiles.lastElementChild;
console.log("unfilled ", removedSpace, " pixels at the bottom");
restorePinnedPosition();
}
const percent = Math.max(0, pinnedTop / UNFILL_TRESHHOLD);
unfillPercent.style.width = `${Math.ceil(percent * 100)}%`;
unfillHandle = null;
}, 1000);
}
}
function removeAtBottom(maxHeight) {
let removeFromNode = tiles.lastElementChild;
let prevNode = removeFromNode.previousElementSibling;
while(prevNode && topFromBottom(prevNode) < maxHeight && prevNode !== pinnedNode) {
removeFromNode = prevNode;
prevNode = removeFromNode.previousElementSibling;
}
const height = topFromBottom(removeFromNode);
while(removeFromNode) {
const tmp = removeFromNode.nextElementSibling;
removeFromNode.remove();
removeFromNode = tmp;
}
return height;
}
const WORDS = ["foo", "bar", "hippo", "giraffe", "rollercoaster", "💩", "🌍"];
let wordIdx = 0;
function createMessage() {
const li = document.createElement("li");
++wordIdx;
if (wordIdx >= WORDS.length) wordIdx = 0;
const message = (WORDS[wordIdx]+" ").repeat(Math.ceil(Math.random() * 100));
li.appendChild(document.createTextNode(message));
return li;
}
function append() {
tiles.appendChild(createMessage());
restorePinnedPosition();
}
let appendTimer = setInterval(append, 500);
document.getElementById("append-check").addEventListener("change", () => {
if (appendTimer) {
clearInterval(appendTimer);
appendTimer = null;
} else {
appendTimer = setInterval(append, 500);
}
});
async function loadAtTop() {
const frag = document.createDocumentFragment();
for (var i = 10 - 1; i >= 0; i--) {
frag.appendChild(createMessage());
}
tiles.insertBefore(frag, tiles.firstElementChild.nextElementSibling);
}
const SIZES = ["0 0 auto", "0 0 200px", "0 0 400px"];
let sizeIdx = 0;
let resizeNode = tiles.lastElementChild;
function resizeLast() {
++sizeIdx;
if (sizeIdx >= SIZES.length) {
sizeIdx = 0;
}
resizeNode.style.flex = SIZES[sizeIdx];
restorePinnedPosition();
}
let resizeTimer = setInterval(resizeLast, 1789);
document.getElementById("resize-check").addEventListener("change", () => {
if (resizeTimer) {
clearInterval(resizeTimer);
resizeTimer = null;
} else {
resizeTimer = setInterval(resizeLast, 1789);
}
});
window.addEventListener("resize", () => {
restorePinnedPosition();
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment