Skip to content

Instantly share code, notes, and snippets.

@coolaj86
Last active October 2, 2024 07:32
Show Gist options
  • Save coolaj86/e8705f3286aef2ad35c9a99e78229c76 to your computer and use it in GitHub Desktop.
Save coolaj86/e8705f3286aef2ad35c9a99e78229c76 to your computer and use it in GitHub Desktop.
<!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