diff --git a/docs/plugins/Carousel.md b/docs/plugins/Carousel.md new file mode 100644 index 000000000..65bec662c --- /dev/null +++ b/docs/plugins/Carousel.md @@ -0,0 +1,50 @@ +--- +title: Carousel +--- + +The Carousel plugin transforms custom `` tags in your Markdown into interactive image galleries with navigation controls, dot indicators, and modal viewing capabilities. + +## Syntax + +```markdown + +First image +Second image +Third image + +``` + +## Configuration + +```typescript +const config: QuartzConfig = { + configuration: { + // ... + }, + plugins: { + transformers: [ + // ... + Plugin.Carousel({ + showDots: true, // Show dot indicators (default: true) + }), + ], + filters: [ + // ... + ], + emitters: [ + // ... + ], + }, +} +``` + +## Features + +The carousel automatically handles multiple images and provides intuitive navigation controls for users to browse through your image collections. + +- Navigation: Click arrows or use keyboard (←/→ keys) +- Touch Support: Swipe gestures on mobile devices +- Modal View: Click images for full-screen viewing +- Responsive: Adapts to different screen sizes +- Accessibility: ARIA labels and keyboard navigation +- Theme Support: Works with light/dark modes diff --git a/quartz/components/scripts/carousel.inline.ts b/quartz/components/scripts/carousel.inline.ts new file mode 100644 index 000000000..4f881ccdd --- /dev/null +++ b/quartz/components/scripts/carousel.inline.ts @@ -0,0 +1,320 @@ +interface CarouselInstance { + currentIndex: number + goToSlide: (index: number) => void + destroy: () => void +} + +// Cache for initialized carousels to avoid re-processing +const initializedCarousels = new WeakSet() + +document.addEventListener("DOMContentLoaded", () => { + const contentArea = + document.querySelector("article") || + document.querySelector("#quartz-body .center") || + document.body + + initAllCarousels(contentArea) + setupCarouselObserver(contentArea) + + // Make initCarousel available globally + ;(window as any).initCarousel = initCarousel +}) + +// Initializes all carousels within the specified container +function initAllCarousels(container: Element): void { + const carousels = container.querySelectorAll( + '.quartz-carousel[data-needs-init="true"]', + ) + carousels.forEach(initCarousel) +} + +// Setup a MutationObserver to watch for new carousels being added to the DOM +function setupCarouselObserver(contentArea: Element): void { + let debounceTimer: number | null = null + + const observer = new MutationObserver((mutations) => { + const hasNewCarousels = mutations.some((mutation) => + Array.from(mutation.addedNodes).some((node) => { + if (node.nodeType !== Node.ELEMENT_NODE) return false + + const element = node as HTMLElement + + // Check if the node itself is a carousel + if ( + element.classList?.contains("quartz-carousel") && + element.getAttribute("data-needs-init") === "true" + ) { + return true + } + + // Check for nested carousels + return element.querySelectorAll?.('.quartz-carousel[data-needs-init="true"]').length > 0 + }), + ) + + if (hasNewCarousels) { + // Debounce to avoid multiple rapid initializations + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = window.setTimeout(() => initAllCarousels(contentArea), 50) + } + }) + + observer.observe(contentArea, { + childList: true, + subtree: true, + }) +} + +// Create a modal for displaying images in full screen +function createImageModal(): HTMLElement { + const modal = document.createElement("div") + modal.className = "carousel-image-modal" + modal.innerHTML = ` + + ` + + document.body.appendChild(modal) + return modal +} + +// Show the image in a modal when clicked +function showImageModal(img: HTMLImageElement): void { + let modal = document.querySelector(".carousel-image-modal") as HTMLElement + + if (!modal) { + modal = createImageModal() + } + + const modalImg = modal.querySelector(".carousel-modal-image") as HTMLImageElement + const closeBtn = modal.querySelector(".carousel-modal-close") as HTMLButtonElement + const overlay = modal.querySelector(".carousel-modal-overlay") as HTMLElement + + // Set image source and alt + modalImg.src = img.src + modalImg.alt = img.alt + + // Show modal + modal.style.display = "flex" + document.body.style.overflow = "hidden" + + // Close handlers + const closeModal = () => { + modal.style.display = "none" + document.body.style.overflow = "" + } + + // Remove existing listeners to avoid duplicates + closeBtn.onclick = null + overlay.onclick = null + + closeBtn.onclick = closeModal + overlay.onclick = (e) => { + if (e.target === overlay) { + closeModal() + } + } + + // Keyboard handler (Escape key) + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + closeModal() + document.removeEventListener("keydown", handleKeyDown) + } + } + + document.addEventListener("keydown", handleKeyDown) +} + +// Initialize a carousel and return an instance +function initCarousel(carousel: HTMLElement): CarouselInstance | null { + // Prevent re-initialization using WeakSet + if (initializedCarousels.has(carousel) || carousel.getAttribute("data-needs-init") !== "true") { + return null + } + + // Mark as initialized + initializedCarousels.add(carousel) + carousel.removeAttribute("data-needs-init") + + const slidesContainer = carousel.querySelector(".quartz-carousel-slides") + if (!slidesContainer) { + return null + } + + const slides = slidesContainer.querySelectorAll(".quartz-carousel-slide") + const dotsContainer = carousel.querySelector(".quartz-carousel-dots") + const prevButton = carousel.querySelector(".quartz-carousel-prev") + const nextButton = carousel.querySelector(".quartz-carousel-next") + + let currentIndex = 0 + + // Early return for single slide + if (slides.length <= 1) { + hideNavigationElements() + setupImageClickHandlers() + return createCarouselInstance() + } + + setupDots() + setupNavigation() + setupKeyboardNavigation() + setupTouchNavigation() + setupImageClickHandlers() + + // Initialize first slide + goToSlide(0) + + function hideNavigationElements(): void { + prevButton?.style.setProperty("display", "none") + nextButton?.style.setProperty("display", "none") + dotsContainer?.style.setProperty("display", "none") + } + + function setupImageClickHandlers(): void { + slides.forEach((slide) => { + const img = slide.querySelector("img") + if (img) { + img.style.cursor = "pointer" + img.addEventListener( + "click", + (e) => { + e.stopPropagation() + showImageModal(img) + }, + { passive: true }, + ) + } + }) + } + + function setupDots(): void { + if (!dotsContainer) return + + // Use DocumentFragment for better performance + const fragment = document.createDocumentFragment() + + slides.forEach((_, index) => { + const dot = document.createElement("span") + dot.className = index === 0 ? "dot active" : "dot" + dot.addEventListener("click", () => goToSlide(index), { passive: true }) + fragment.appendChild(dot) + }) + + dotsContainer.innerHTML = "" + dotsContainer.appendChild(fragment) + } + + function setupNavigation(): void { + const handlePrevClick = (e: Event): void => { + e.preventDefault() + goToSlide(currentIndex - 1) + } + + const handleNextClick = (e: Event): void => { + e.preventDefault() + goToSlide(currentIndex + 1) + } + + prevButton?.addEventListener("click", handlePrevClick, { passive: false }) + nextButton?.addEventListener("click", handleNextClick, { passive: false }) + } + + // Setup keyboard navigation (Arrow keys) + function setupKeyboardNavigation(): void { + carousel.setAttribute("tabindex", "0") + carousel.addEventListener( + "keydown", + (e: KeyboardEvent) => { + switch (e.key) { + case "ArrowLeft": + e.preventDefault() + goToSlide(currentIndex - 1) + break + case "ArrowRight": + e.preventDefault() + goToSlide(currentIndex + 1) + break + } + }, + { passive: false }, + ) + } + + // Setup touch navigation (swipe gestures) + function setupTouchNavigation(): void { + let touchStartX = 0 + let touchEndX = 0 + + carousel.addEventListener( + "touchstart", + (e: TouchEvent) => { + touchStartX = e.changedTouches[0].screenX + }, + { passive: true }, + ) + + carousel.addEventListener( + "touchend", + (e: TouchEvent) => { + touchEndX = e.changedTouches[0].screenX + handleSwipe() + }, + { passive: true }, + ) + + function handleSwipe(): void { + const minSwipeDistance = 50 + const swipeDistance = touchEndX - touchStartX + + if (Math.abs(swipeDistance) < minSwipeDistance) return + + if (swipeDistance < 0) { + goToSlide(currentIndex + 1) // Swipe left -> next + } else { + goToSlide(currentIndex - 1) // Swipe right -> prev + } + } + } + + // Function to go to a specific slide + function goToSlide(index: number): void { + // Handle wrapping + currentIndex = ((index % slides.length) + slides.length) % slides.length + + // Use transform for better performance + if (slidesContainer) { + slidesContainer.style.transform = `translateX(-${currentIndex * 100}%)` + } + + // Update dots efficiently + if (dotsContainer) { + const dots = dotsContainer.querySelectorAll(".dot") + dots.forEach((dot, i) => { + dot.classList.toggle("active", i === currentIndex) + }) + } + } + + // Create and return the carousel instance that references the carousel element + function createCarouselInstance(): CarouselInstance { + return { + currentIndex, + goToSlide, + destroy: () => { + initializedCarousels.delete(carousel) + carousel.setAttribute("data-needs-init", "true") + }, + } + } + + return createCarouselInstance() +} diff --git a/quartz/components/styles/carousel.inline.scss b/quartz/components/styles/carousel.inline.scss new file mode 100644 index 000000000..04f6274d7 --- /dev/null +++ b/quartz/components/styles/carousel.inline.scss @@ -0,0 +1,234 @@ +// Variables +$carousel-control-bg: #f0f0f0; +$carousel-control-color: #333; +$carousel-control-hover-bg: #ddd; +$carousel-control-size: 40px; + +article .quartz-carousel { + position: relative; + width: 100%; + margin: 2rem 0; + overflow: hidden; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + + .quartz-carousel-slides { + display: flex; + transition: transform 0.5s ease-in-out; + + .quartz-carousel-slide { + flex: 0 0 100%; + display: flex; + justify-content: center; + align-items: center; + + img { + max-width: 100%; + max-height: 400px; + object-fit: contain; + display: block; + transition: opacity 0.2s ease; + + &:hover { + opacity: 0.9; + } + } + } + } + + .quartz-carousel-dots { + text-align: center; + margin-top: 1rem; + padding: 0.5rem 0; + + .dot { + width: 10px; + height: 10px; + margin: 0 5px; + background-color: #ddd; + border-radius: 50%; + display: inline-block; + transition: background-color 0.3s ease; + cursor: pointer; + + &.active { + background-color: #555; + } + } + } + + .quartz-carousel-prev, + .quartz-carousel-next { + position: absolute; + top: 50%; + transform: translateY(-50%); + background-color: $carousel-control-bg; + border: none; + border-radius: 50%; + width: $carousel-control-size; + height: $carousel-control-size; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.3s ease; + + svg { + width: 24px; + height: 24px; + fill: $carousel-control-color; + } + + &:hover { + background-color: $carousel-control-hover-bg; + } + } + + .quartz-carousel-prev { + left: 10px; + } + + .quartz-carousel-next { + right: 10px; + } +} + +// Image Modal Styles +.carousel-image-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 9999; + + .carousel-modal-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.9); + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + box-sizing: border-box; + } + + .carousel-modal-content { + position: relative; + max-width: 90vw; + max-height: 90vh; + display: flex; + justify-content: center; + align-items: center; + } + + .carousel-modal-image { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + } + + .carousel-modal-close { + position: absolute; + top: -50px; + right: -50px; + background-color: rgba(255, 255, 255, 0.9); + border: none; + border-radius: 50%; + width: 40px; + height: 40px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.3s ease; + z-index: 10001; + + svg { + width: 24px; + height: 24px; + fill: #333; + } + + &:hover { + background-color: rgba(255, 255, 255, 1); + } + } +} + +// Dark mode adjustments +html[saved-theme="dark"] article .quartz-carousel, +html[saved-theme="dark"] .carousel-image-modal { + .quartz-carousel-prev, + .quartz-carousel-next { + background-color: rgba(50, 50, 50, 0.8); + + svg { + fill: #eee; + } + + &:hover { + background-color: rgba(70, 70, 70, 0.95); + } + } + + .dot { + background-color: #555; + + &.active { + background-color: #ddd; + } + } + + .carousel-modal-close { + background-color: rgba(50, 50, 50, 0.9); + + svg { + fill: #eee; + } + + &:hover { + background-color: rgba(70, 70, 70, 1); + } + } +} + +// Mobile responsiveness +@media (max-width: 768px) { + article .quartz-carousel { + .quartz-carousel-prev, + .quartz-carousel-next { + width: 35px; + height: 35px; + + svg { + width: 18px; + height: 18px; + } + } + } + + .carousel-image-modal { + .carousel-modal-overlay { + padding: 1rem; + } + + .carousel-modal-close { + top: -35px; + right: -35px; + width: 35px; + height: 35px; + + svg { + width: 20px; + height: 20px; + } + } + } +} diff --git a/quartz/plugins/transformers/carousel.ts b/quartz/plugins/transformers/carousel.ts new file mode 100644 index 000000000..2c52807a1 --- /dev/null +++ b/quartz/plugins/transformers/carousel.ts @@ -0,0 +1,79 @@ +import { QuartzTransformerPlugin } from "../types" +import { visit } from "unist-util-visit" + +// @ts-ignore +import carouselScript from "../../components/scripts/carousel.inline" +import carouselStyle from "../../components/styles/carousel.inline.scss" + +interface CarouselOptions { + showDots: boolean +} + +export const Carousel: QuartzTransformerPlugin> = (opts) => { + const showDots = opts?.showDots ?? true + + function carouselTransformer() { + return (tree: any) => { + visit(tree, "html", (node: any) => { + // Check if the node contains a carousel tag + const content = node.value as string + if (content.startsWith("") && content.endsWith("")) { + // Extract the content inside the carousel tag + const innerContent = content.slice("".length, -"".length).trim() + + // Process images correctly by using a more reliable approach + const processedContent = innerContent + .split(" { + if (index === 0) return "" // Skip the first part before any img tag + return `