Last active
September 2, 2021 15:00
-
-
Save bwindels/d81a734762dae55650cde328b0fcaba3 to your computer and use it in GitHub Desktop.
timeline-scrollby.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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