Skip to content

Instantly share code, notes, and snippets.

@bwindels
Last active September 2, 2021 15:00
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bwindels/d81a734762dae55650cde328b0fcaba3 to your computer and use it in GitHub Desktop.
Save bwindels/d81a734762dae55650cde328b0fcaba3 to your computer and use it in GitHub Desktop.
timeline-scrollby.html
<!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;
}
#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 {
display: flex;
flex-direction: column;
margin: 0;
padding: 10px;
list-style: none;
}
#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;
}
</style>
</head>
<body>
<div id="timeline" class="stickToBottom">
<div id="settings">
<p><input type="checkbox" id="insert-check" checked><label id="status" for="insert-check">Insert nodes</label></p>
<p><input type="checkbox" id="scroll-handler-check" checked><label for="scroll-handler-check">Scroll event handler</label></p>
<p><input type="checkbox" id="resize-check" checked><label for="resize-check">Resize first post</label></p>
<p><input type="checkbox" id="restoreposition-check" checked><label for="restoreposition-check">Restore position</label></p>
</div>
<ol id="tiles">
<li>First post</li>
</ol>
</div>
<script type="text/javascript">
const timeline = document.getElementById("timeline");
const tiles = document.getElementById("tiles");
const status = document.getElementById("status");
const scrollCheck = document.getElementById("scroll-handler-check");
const resizeCheck = document.getElementById("resize-check");
const restorePositionCheck = document.getElementById("restoreposition-check");
function viewportBottom() {
return timeline.scrollTop + timeline.clientHeight;
}
function bottom(node) {
return node.offsetTop + node.clientHeight;
}
function findLastNodeInViewport(vpBottom) {
for (var i = tiles.children.length - 1; i >= 0; i--) {
const node = tiles.children[i];
if (node.offsetTop < vpBottom) {
return node;
}
}
}
let pinnedNode;
let pinnedBottom;
let stickToBottom = true;
let isScrollingHandle = null;
let lastScrollTop = 0;
let expectedScrollDiff = 0;
timeline.addEventListener("scroll", () => {
if (!scrollCheck.checked) {
console.log("ignoring scroll because not checked");
return;
}
const {scrollHeight, scrollTop, clientHeight} = timeline;
if (expectedScrollDiff) {
const scrollDiff = scrollTop - lastScrollTop;
if (scrollDiff === expectedScrollDiff) {
expectedScrollDiff = 0;
console.log("ignoring scroll echo");
return;
} else {
console.log("expecting scroll echo, but different", expectedScrollDiff, scrollDiff);
}
}
pinnedNode?.classList.remove("pinned");
const isAtBottom = Math.abs(scrollHeight - (scrollTop + clientHeight)) < 1;;
if (isAtBottom !== stickToBottom) {
if (isAtBottom) {
timeline.classList.add("stickToBottom");
} else {
timeline.classList.remove("stickToBottom");
}
stickToBottom = isAtBottom;
}
if (!isAtBottom) {
// save bottom node position
const viewportBottom = scrollTop + clientHeight;
pinnedNode = findLastNodeInViewport(viewportBottom);
pinnedNode.classList.add("pinned");
pinnedBottom = bottom(pinnedNode);
// console.log("onscroll: viewportBottom()", viewportBottom(), "pinnedBottom", pinnedBottom);
}
if (isScrollingHandle) {
clearTimeout(isScrollingHandle);
}
lastScrollTop = scrollTop;
timeline.style.setProperty("overflow-anchor", "auto");
isScrollingHandle = setTimeout(() => {
isScrollingHandle = null;
timeline.style.removeProperty("overflow-anchor");
}, 10);
});
function isAtBottom() {
return Math.abs(timeline.scrollHeight - (timeline.scrollTop + timeline.clientHeight)) < 1;
}
let isRestoring = false;
function restorePinnedPosition() {
if (isRestoring) {
return;
}
if (!restorePositionCheck.checked) {
console.log("ignoring restore because not checked");
return;
}
if (isScrollingHandle) {
console.log("don't restore while scrolling");
return;
}
isRestoring = true;
requestAnimationFrame(() => {
// ensure the messages are bottom aligned when there is not enough content to fill the viewport
const heightDiff = timeline.clientHeight - tiles.clientHeight;
if (heightDiff > 0) {
tiles.style.setProperty("margin-top", `${heightDiff}px`);
} else {
tiles.style.removeProperty("margin-top");
}
if (stickToBottom) {
timeline.scrollTop = timeline.scrollHeight;
console.log(`scroll to bottom as height changed`);
} else {
const newPinnedBottom = bottom(pinnedNode);
if (newPinnedBottom !== pinnedBottom) {
const bottomDiff = newPinnedBottom - pinnedBottom;
console.log(`scrollBy ${bottomDiff} as height changed`);
expectedScrollDiff = bottomDiff;
timeline.scrollBy(0, bottomDiff);
pinnedBottom = newPinnedBottom;
}
}
isRestoring = false;
});
}
restorePinnedPosition();
let prependOrAppend = true;
const WORDS = ["foo", "bar", "hippo", "giraffe", "rollercoaster", "💩", "🌍"];
let wordIdx = 0;
const middleIndex = 5000;
let added = 0;
function insert() {
const li = document.createElement("li");
++wordIdx;
if (wordIdx >= WORDS.length) wordIdx = 0;
let index;
const message = (WORDS[wordIdx]+" ").repeat(Math.ceil(Math.random() * 100));
added += 1;
if (prependOrAppend) {
const index = middleIndex - Math.ceil(added / 2);
li.appendChild(document.createTextNode(`${index}: ${message}`));
tiles.insertBefore(li, tiles.firstElementChild);
} else {
const index = middleIndex + Math.floor(added / 2);
li.appendChild(document.createTextNode(`${index}: ${message}`));
tiles.appendChild(li);
}
prependOrAppend = !prependOrAppend;
status.textContent = `about to ${prependOrAppend ? "prepend" : "append"}`;
restorePinnedPosition();
}
let insertTimer = setInterval(insert, 500);
document.getElementById("insert-check").addEventListener("change", () => {
if (insertTimer) {
clearInterval(insertTimer);
insertTimer = null;
status.textContent = "stopped";
} else {
insertTimer = setInterval(insert, 500);
status.textContent = "started";
}
});
const SIZES = ["auto", "0 0 200px", "0 0 400px"];
let sizeIdx = 0;
let resizeNode = tiles.lastElementChild;
function resizeLast() {
if (!resizeCheck.checked) {
return;
}
++sizeIdx;
if (sizeIdx >= SIZES.length) {
sizeIdx = 0;
}
resizeNode.style.flex = SIZES[sizeIdx];
restorePinnedPosition();
}
let resizeTimer = setInterval(resizeLast, 1789);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment