Last active
October 2, 2024 07:32
-
-
Save coolaj86/e8705f3286aef2ad35c9a99e78229c76 to your computer and use it in GitHub Desktop.
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 lang="en"> | |
<head> | |
<meta charset="UTF-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
<title> | |
Multi-Section Code Carousel with Active Section and Hash Routing | |
</title> | |
<link | |
href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" | |
rel="stylesheet" | |
/> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script> | |
<style> | |
:root { | |
--font-size: 2cqw; | |
} | |
* { | |
margin: 0; | |
padding: 0; | |
box-sizing: border-box; | |
} | |
body { | |
width: 100%; | |
display: flex; | |
} | |
aside { | |
width: 20%; | |
transition: /* Smooth collapse */ | |
left 0.3s ease-in-out, | |
width 0.3s ease, | |
padding 0.3s ease; | |
} | |
nav { | |
position: fixed; | |
top: 0; | |
left: 0; | |
width: 20%; | |
height: 100%; | |
background-color: #f4f4f4; | |
padding: 20px; | |
border-right: 2px solid #ccc; | |
flex-shrink: 0; | |
overflow-y: auto; | |
transition: /* Smooth collapse */ | |
left 0.3s ease-in-out, | |
width 0.3s ease, | |
padding 0.3s ease; | |
& ul { | |
list-style-type: none; | |
} | |
& ol { | |
cursor: pointer; | |
margin-bottom: 10px; | |
padding: 10px; | |
background-color: #ddd; | |
border-radius: 5px; | |
transition: background-color 0.3s; | |
} | |
& ol:hover { | |
background-color: #bbb; | |
} | |
& .miniature { | |
width: 100%; /* Scale miniature relative to its container */ | |
aspect-ratio: 16 / 9; /* Maintain 16:9 aspect ratio */ | |
background-color: #f9f9f9; | |
border: 1px solid #ccc; | |
border-radius: 5px; | |
overflow: hidden; | |
margin-top: 10px; | |
} | |
& .miniature-content { | |
padding: 5px; /* Reduce padding for miniature version */ | |
/* font-size: var(--font-size); */ | |
font-size: 0.25cqw; | |
} | |
} | |
.collapsed { | |
width: 0; | |
padding: 0; | |
overflow: hidden; | |
left: -25%; | |
} | |
pre { | |
font-family: monospace; | |
font-size: 1.2em; | |
line-height: 1.5; | |
position: relative; | |
outline: none; /* Remove focus outline/border on <pre> */ | |
} | |
ol { | |
counter-reset: line-number; | |
list-style: none; | |
padding-left: 40px; /* Space for gutter */ | |
position: relative; | |
} | |
li { | |
counter-increment: line-number; | |
position: relative; | |
min-height: 1.2em; /* Ensures space is taken by empty lines */ | |
} | |
li::before { | |
content: counter(line-number); | |
position: absolute; | |
left: -30px; | |
width: 20px; | |
text-align: right; | |
opacity: 0.5; | |
} | |
/* Dimming styles */ | |
ol .dimmed { | |
opacity: 0.3; | |
transition: opacity 0.4s ease; | |
} | |
ol .focused { | |
opacity: 1; | |
transition: opacity 0.4s ease; | |
} | |
main { | |
flex-grow: 1; | |
padding: 20px; | |
overflow-y: auto; | |
container-type: inline-size; | |
} | |
article { | |
width: 100%; | |
display: flex; | |
flex-direction: column; | |
height: auto; | |
flex-grow: 1; | |
gap: 20px; | |
font-size: 2cqw; | |
} | |
section { | |
transition: /* Smooth collapse */ width 0.3s ease; | |
border: 2px solid transparent; | |
padding: 20px; | |
margin-bottom: 50px; | |
width: 100%; | |
aspect-ratio: 16 / 9; | |
} | |
section.active { | |
border-color: blue; | |
background-color: #f0f8ff; | |
} | |
.miniature { | |
font-size: var(--font-size); | |
} | |
</style> | |
</head> | |
<body> | |
<aside> | |
<nav> | |
<button onclick="toggleNav(); document.exitFullscreen()"> | |
Exit ↙️ | |
</button> | |
<button | |
onclick="toggleNav(); document.documentElement.requestFullscreen()" | |
> | |
Fullscreen ↗️ | |
</button> | |
<ul></ul> | |
</nav> | |
</aside> | |
<main> | |
<article> | |
<h2>Section 1</h2> | |
<carousel | |
data-line-start="5" | |
data-slides="5-6, 8-9 | 9, 10-11 | 5, 16, 17" | |
></carousel> | |
<pre><code class="language-js"> | |
function hello() { | |
console.log("Hello, world!"); | |
} | |
function goodbye() { | |
console.log("Goodbye, world!"); | |
} | |
let name = "Alice"; | |
let greeting = hello(); | |
console.log(greeting); | |
goodbye(); | |
console.log("End"); | |
</code></pre> | |
<hr /> | |
<h2>Section 2</h2> | |
<carousel | |
data-line-start="1" | |
data-slides="1-2 | 3-4 | 5" | |
></carousel> | |
<pre><code class="language-js"> | |
function greet(name) { | |
return "Hello, " + name + "!"; | |
} | |
console.log(greet("Alice")); | |
</code></pre> | |
<hr /> | |
<h2>Section 3</h2> | |
<carousel | |
data-line-start="3" | |
data-slides="3-4, 5-6 | 7 | 8-9" | |
></carousel> | |
<pre><code class="language-js"> | |
const PI = 3.14159; | |
function area(radius) { | |
return PI * radius * radius; | |
} | |
console.log(area(5)); | |
</code></pre> | |
<hr /> | |
</article> | |
</main> | |
<script> | |
function toggleNav() { | |
const nav = document.querySelector("nav"); | |
const aside = document.querySelector("aside"); | |
nav.classList.toggle("collapsed"); | |
aside.classList.toggle("collapsed"); | |
} | |
document.addEventListener("DOMContentLoaded", function () { | |
const article = document.querySelector("article"); | |
markdownToSlides(article); | |
const navList = document.querySelector("nav ul"); | |
const sections = document.querySelectorAll("section"); | |
createMinis(navList, sections); | |
let activeSection = sections[0]; | |
let manualActivation = false; // Manual activation flag to prevent conflicts during scrolling | |
// Initialize the page | |
initSections(sections); | |
adjustBottomPadding(); // Ensure bottom padding is correct on load | |
setupScrollActivation(); // Set up scroll-based section activation | |
handleHashChange(); // Activate based on the initial hash | |
window.addEventListener("hashchange", handleHashChange); // Handle hash changes | |
let isScrolling; | |
let lastPosition = window.scrollY; | |
window.addEventListener("scroll", function () { | |
// Clear the previous timeout | |
clearTimeout(isScrolling); | |
// Check if the scroll position has changed | |
if (lastPosition !== window.scrollY) { | |
lastPosition = window.scrollY; | |
// Set a timeout to detect if scrolling has stopped | |
isScrolling = setTimeout(function () { | |
manualActivation = false; | |
}, 50); // The timeout duration can be adjusted | |
} | |
}); | |
document.addEventListener("keydown", function (event) { | |
console.log(event.key, event.code); | |
if (event.key === "Escape") { | |
toggleNav(); | |
} | |
}); | |
// ======================== COMPONENTS ========================== // | |
// Component 0: Markdown to Slides | |
function markdownToSlides(article) { | |
const slides = []; | |
console.log("New Slide"); | |
let currentSlide = null; | |
let currentCount = 0; | |
let nodeList = Array.from(article.childNodes); | |
for (let node of nodeList) { | |
if (!currentSlide) { | |
console.log("New Slide"); | |
currentCount = 0; | |
currentSlide = document.createElement("section"); | |
slides.push(currentSlide); | |
} | |
if (node.tagName === "HR") { | |
// When an <hr> is encountered, finalize the current slide | |
if (currentCount === 0) { | |
currentSlide.textContent = | |
"(this slide unintentionally left blank)"; | |
} | |
currentSlide = null; | |
continue; | |
} | |
// move the node to the current slide | |
console.log(" section child", node); | |
currentSlide.appendChild(node); | |
let text = node.textContent.trim(); | |
if (text.length > 0) { | |
currentCount += 1; | |
} | |
} | |
if (currentSlide) { | |
if (currentCount === 0) { | |
currentSlide.textContent = | |
"(this slide unintentionally left blank)"; | |
} | |
} | |
article.textContent = ""; | |
slides.forEach((slide) => { | |
article.appendChild(slide); | |
}); | |
} | |
function createMinis(navList, sections) { | |
sections.forEach(function (section, index) { | |
// Get the header text for each section | |
const sectionHeader = | |
section.querySelector("h2")?.textContent || | |
`Untitled (${index})`; | |
// Create an <ol> element for navigation | |
const navItem = document.createElement("ol"); | |
navItem.textContent = sectionHeader; | |
navItem.onclick = function () { | |
triggerClickOnSection(sections[index]); | |
}; | |
// Clone the section for the miniature and adjust for miniature display | |
const miniatureSection = document.createElement("div"); | |
miniatureSection.classList.add("miniature"); | |
const miniatureContent = section.cloneNode(true); | |
miniatureContent.classList.add("miniature-content"); | |
miniatureSection.appendChild(miniatureContent); | |
// Add the miniature section to the nav item | |
navItem.appendChild(miniatureSection); | |
// Add the nav item to the list | |
navList.appendChild(navItem); | |
}); | |
} | |
// Component 1: Section Management | |
function initSections(sections) { | |
sections.forEach(function (section) { | |
setupCarousel(section); | |
setupSectionActivation(section); | |
}); | |
setupGlobalKeyListeners(); // Handle up/down/space key navigation | |
setupResizeListener(); // Add listener for window resizing | |
} | |
function setupSectionActivation(section) { | |
section.addEventListener("click", () => { | |
manualActivation = true; | |
activateSection(section, true); // Scroll into view when clicked | |
updateHash(section); // Update hash based on selection | |
}); | |
} | |
function activateSection(section, shouldScroll) { | |
if (activeSection) { | |
activeSection.classList.remove("active"); | |
} | |
activeSection = section; | |
activeSection.classList.add("active"); | |
// Scroll into view if required (but only for manual activation, not scroll activation) | |
if (shouldScroll) { | |
activeSection.scrollIntoView({ | |
behavior: "smooth", | |
block: "start", | |
}); | |
} | |
updateHash(section); // Update hash whenever section changes | |
} | |
// Component 2: Carousel Navigation (dimming/focusing lines, arrow key navigation) | |
function setupCarousel(section) { | |
const carousel = section.querySelector("carousel"); | |
if (!carousel) { | |
return; | |
} | |
const pre = section.querySelector("pre"); | |
const code = pre.querySelector("code"); | |
const lineStart = | |
Number(carousel.getAttribute("data-line-start")) || 1; | |
const slideGroups = carousel | |
.getAttribute("data-slides") | |
.split(/[|;]/); | |
let currentSlide = 0; | |
Prism.highlightAll(); | |
wrapLinesWithNumbers(pre, code, lineStart); | |
applyLineFocus( | |
pre, | |
lineStart, | |
parseSlideRanges(slideGroups[currentSlide]) | |
); | |
document.addEventListener("keydown", function (e) { | |
if (section === activeSection) { | |
handleArrowNavigation( | |
e, | |
slideGroups, | |
currentSlide, | |
pre, | |
lineStart | |
); | |
} | |
}); | |
// Function to navigate to a specific slide (1-indexed) | |
section.gotoSlide = function (slideNumber) { | |
currentSlide = Math.max( | |
0, | |
Math.min(slideGroups.length - 1, slideNumber - 1) | |
); | |
let slideRanges = parseSlideRanges( | |
slideGroups[currentSlide] | |
); | |
applyLineFocus(pre, lineStart, slideRanges); | |
updateHash(section, currentSlide + 1); // Update hash based on slide number | |
}; | |
} | |
function handleArrowNavigation( | |
e, | |
slideGroups, | |
currentSlide, | |
pre, | |
lineStart | |
) { | |
if ( | |
e.key === "ArrowRight" && | |
currentSlide < slideGroups.length - 1 | |
) { | |
currentSlide += 1; | |
} else if (e.key === "ArrowLeft" && currentSlide > 0) { | |
currentSlide -= 1; | |
} | |
let slideRanges = parseSlideRanges( | |
slideGroups[currentSlide] | |
); | |
applyLineFocus(pre, lineStart, slideRanges); | |
updateHash(activeSection, currentSlide + 1); // Update hash on manual navigation | |
} | |
function wrapLinesWithNumbers(pre, code, startLine) { | |
const html = code.innerHTML.trim(); | |
const lines = html.split("\n"); | |
const ol = document.createElement("ol"); | |
ol.style.counterReset = `line-number ${startLine - 1}`; | |
lines.forEach((line) => { | |
const li = document.createElement("li"); | |
li.innerHTML = line; | |
ol.appendChild(li); | |
}); | |
pre.innerHTML = ""; | |
pre.appendChild(ol); | |
} | |
function applyLineFocus(pre, lineStart, selectedLineNumbers) { | |
const lines = pre.querySelectorAll("ol li"); | |
lines.forEach((line, index) => { | |
const lineNumber = index + lineStart; | |
if (selectedLineNumbers.includes(lineNumber)) { | |
line.classList.add("focused"); | |
line.classList.remove("dimmed"); | |
} else { | |
line.classList.add("dimmed"); | |
line.classList.remove("focused"); | |
} | |
}); | |
} | |
function parseSlideRanges(slideRange) { | |
const ranges = slideRange.split(/[,\s]+/); | |
let selectedLines = []; | |
ranges.forEach((range) => { | |
if (range.includes("-")) { | |
let [start, end] = range.split("-").map(Number); | |
for (let i = start; i <= end; i++) { | |
selectedLines.push(i); | |
} | |
} else { | |
selectedLines.push(Number(range)); | |
} | |
}); | |
return selectedLines; | |
} | |
// Component 3: Scroll-based Section Activation (activate section on scroll) | |
function setupScrollActivation() { | |
window.addEventListener("scroll", () => { | |
if (manualActivation) return; // Skip scroll-based activation during manual actions | |
const scrollY = window.scrollY; | |
let newActiveSection = null; | |
sections.forEach((section) => { | |
const sectionTop = section.offsetTop; | |
const sectionHeight = section.offsetHeight; | |
// Check if the section is sufficiently in view (using 50% of its height) | |
if ( | |
scrollY >= sectionTop - sectionHeight * 0.5 && | |
scrollY < sectionTop + sectionHeight * 0.5 | |
) { | |
newActiveSection = section; | |
} | |
}); | |
// Activate the new section, but don't scroll to it (shouldScroll = false) | |
if ( | |
newActiveSection && | |
newActiveSection !== activeSection | |
) { | |
activateSection(newActiveSection, false); // No scrolling when activating via scroll | |
} | |
}); | |
} | |
// Component 4: Global Key Listeners for Section Navigation (up, down, spacebar) | |
function setupGlobalKeyListeners() { | |
window.addEventListener("keydown", function (e) { | |
if (["ArrowUp", "ArrowDown", " "].includes(e.key)) { | |
e.preventDefault(); // Disable default scroll behavior | |
} | |
const currentIndex = | |
Array.from(sections).indexOf(activeSection); | |
if (e.key === "ArrowDown" || e.key === " ") { | |
// Move to the next section on Down or Spacebar key | |
if (currentIndex < sections.length - 1) { | |
triggerClickOnSection( | |
sections[currentIndex + 1] | |
); | |
} | |
} else if (e.key === "ArrowUp") { | |
// Move to the previous section on Up key | |
if (currentIndex > 0) { | |
triggerClickOnSection( | |
sections[currentIndex - 1] | |
); | |
} | |
} | |
}); | |
} | |
// Trigger a click event programmatically | |
function triggerClickOnSection(section) { | |
section.click(); | |
} | |
// Component 5: Dynamic Padding for Final Section | |
function setupResizeListener() { | |
window.addEventListener("resize", adjustBottomPadding); | |
} | |
function adjustBottomPadding() { | |
let dynamicPaddingElement = | |
document.querySelector(".dynamic-padding"); | |
if (!dynamicPaddingElement) { | |
dynamicPaddingElement = document.createElement("div"); | |
dynamicPaddingElement.classList.add("dynamic-padding"); | |
dynamicPaddingElement.style.height = 0; | |
article.appendChild(dynamicPaddingElement); | |
} | |
const lastSection = sections[sections.length - 1]; | |
const viewportHeight = window.innerHeight; | |
const sectionHeight = lastSection.offsetHeight; | |
// Calculate the required padding to align the last section's title at the top | |
const neededPadding = Math.max( | |
0, | |
viewportHeight - sectionHeight | |
); | |
dynamicPaddingElement.style.height = `${neededPadding}px`; | |
} | |
// Component 6: Hash-Based Routing (section and carousel navigation) | |
function handleHashChange() { | |
const hash = window.location.hash.substring(1); // Get hash without '#' | |
const [sectionNumber, slideNumber] = hash | |
.split(".") | |
.map(Number); | |
// Handle section selection (clamp to valid range) | |
let sectionIndex = Math.max( | |
0, | |
Math.min(sections.length - 1, (sectionNumber || 1) - 1) | |
); | |
let targetSection = sections[sectionIndex]; | |
// Activate the section | |
activateSection(targetSection, true); | |
// Handle carousel slide selection, if applicable | |
if (slideNumber && targetSection.gotoSlide) { | |
targetSection.gotoSlide(slideNumber); | |
} | |
} | |
// Component 7: Update Hash without Triggering Hashchange | |
function updateHash(section, slideNumber = null) { | |
const sectionIndex = | |
Array.from(sections).indexOf(section) + 1; // 1-indexed | |
const hash = slideNumber | |
? `#${sectionIndex}.${slideNumber}` | |
: `#${sectionIndex}`; | |
history.replaceState(null, null, hash); // Update hash without triggering hashchange | |
} | |
}); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment