From f909b7a5aac9610e24a2bb3b71562b07e13f649f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=ACnei?= Date: Sat, 7 Jun 2025 18:19:49 -0300 Subject: [PATCH 1/3] Carousel plugin --- docs/plugins/Carousel.md | 50 +++ quartz/components/scripts/carousel.inline.ts | 299 ++++++++++++++++++ quartz/components/styles/carousel.inline.scss | 235 ++++++++++++++ quartz/plugins/transformers/carousel.ts | 72 +++++ quartz/plugins/transformers/index.ts | 1 + 5 files changed, 657 insertions(+) create mode 100644 docs/plugins/Carousel.md create mode 100644 quartz/components/scripts/carousel.inline.ts create mode 100644 quartz/components/styles/carousel.inline.scss create mode 100644 quartz/plugins/transformers/carousel.ts diff --git a/docs/plugins/Carousel.md b/docs/plugins/Carousel.md new file mode 100644 index 000000000..28860f19e --- /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 + +``` +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..935005240 --- /dev/null +++ b/quartz/components/scripts/carousel.inline.ts @@ -0,0 +1,299 @@ +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(); +} \ No newline at end of file diff --git a/quartz/components/styles/carousel.inline.scss b/quartz/components/styles/carousel.inline.scss new file mode 100644 index 000000000..64061f9e9 --- /dev/null +++ b/quartz/components/styles/carousel.inline.scss @@ -0,0 +1,235 @@ +// 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; + } + } + } +} \ No newline at end of file diff --git a/quartz/plugins/transformers/carousel.ts b/quartz/plugins/transformers/carousel.ts new file mode 100644 index 000000000..40c702974 --- /dev/null +++ b/quartz/plugins/transformers/carousel.ts @@ -0,0 +1,72 @@ +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 `