Credits
- Eilleen’s Garden - for mentioning about the “Image Carousel plugin” - https://quartz.eilleeenz.com/Quartz-customization-log
- pinei - for the PR - https://github.com/jackyzha0/quartz/pull/2011
Change sorting order of “Explorer”
Reference
Component.Explorer({
title: "Explorer", // title of the explorer component
folderClickBehavior: "collapse", // what happens when you click a folder ("link" to navigate to folder page on click or "collapse" to collapse folder on click)
folderDefaultState: "collapsed", // default state of folders ("collapsed" or "open")
useSavedState: true, // whether to use local storage to save "state" (which folders are opened) of explorer
// omitted but shown later
sortFn: ...,
filterFn: ...,
mapFn: ...,
// what order to apply functions in
order: ["filter", "map", "sort"],
})
Changing sorting order
Component.Explorer({
sortFn: (a, b) => {
if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) {
return b.displayName.localeCompare(a.displayName, undefined, {
numeric: true,
sensitivity: "base",
})
}
if (!a.isFolder && b.isFolder) {
return 1
} else {
return -1
}
},
})
TIP
Swaps the order of a and b in the localeCompare function, which will sort the items in descending order.
Add Image carousel
Steps
- Add
carousel.ts
toplugins/transformers
folder - source code - Update
plugins/transformers/index.ts
toexport
carousel - source code - Add
carousel.inline.ts
tocomponents/scripts
folder (for behavior) - source code - Add
carousel.inline.scss
tocomponents/styles
folder (for styling) - source code - Add the Carousel transformer to
quartz.config.ts
Testing
Add following HTML tag to Markdown file (as-is)
<Carousel>
<img src="image1.jpg" alt="First image">
<img src="image2.jpg" alt="Second image">
</Carousel>
Note
You can use HTML tag directly. NO NEED to wrap it inside code ``` block
Codes
carousel.ts
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<Partial<CarouselOptions>> = (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("<Carousel>") && content.endsWith("</Carousel>")) {
// Extract the content inside the carousel tag
const innerContent = content.slice("<Carousel>".length, -"</Carousel>".length).trim()
// Process images correctly by using a more reliable approach
const processedContent = innerContent
.split("<img")
.map((part, index) => {
if (index === 0) return "" // Skip the first part before any img tag
return `<div class="quartz-carousel-slide"><img${part}</div>`
})
.join("")
// Replace the node with a div that has a carousel class
node.type = "html"
node.value = `<div class="quartz-carousel" data-needs-init="true">
<div class="quartz-carousel-slides">
${processedContent}
</div>
${showDots ? '<div class="quartz-carousel-dots"></div>' : ""}
<button class="quartz-carousel-prev" aria-label="Previous slide">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"></path>
</svg>
</button>
<button class="quartz-carousel-next" aria-label="Next slide">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"></path>
</svg>
</button>
</div>`
}
})
}
}
return {
name: "Carousel",
markdownPlugins() {
return [carouselTransformer]
},
externalResources() {
return {
css: [
{
content: carouselStyle,
inline: true,
},
],
js: [
{
script: carouselScript,
loadTime: "afterDOMReady",
contentType: "inline",
},
],
}
},
}
}
carousel.inline.ts
interface CarouselInstance {
currentIndex: number
goToSlide: (index: number) => void
destroy: () => void
}
// Cache for initialized carousels to avoid re-processing
const initializedCarousels = new WeakSet<HTMLElement>()
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<HTMLElement>(
'.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 = `
<div class="carousel-modal-overlay">
<div class="carousel-modal-content">
<button class="carousel-modal-close" aria-label="Close modal">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>
<img class="carousel-modal-image" src="" alt="" />
</div>
</div>
`
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<HTMLElement>(".quartz-carousel-slides")
if (!slidesContainer) {
return null
}
const slides = slidesContainer.querySelectorAll<HTMLElement>(".quartz-carousel-slide")
const dotsContainer = carousel.querySelector<HTMLElement>(".quartz-carousel-dots")
const prevButton = carousel.querySelector<HTMLButtonElement>(".quartz-carousel-prev")
const nextButton = carousel.querySelector<HTMLButtonElement>(".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<HTMLElement>(".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()
}
carousel.inline.scss
// 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;
}
}
}
}
index.ts
...
export { RoamFlavoredMarkdown } from "./roam"
export { ClickableImages } from "./clickableImages"
export { Carousel } from "./carousel"
quartz.config.ts
plugins: {
transformers: [
Plugin.FrontMatter(),
Plugin.CreatedModifiedDate({
priority: ["frontmatter", "git", "filesystem"],
}),
Plugin.SyntaxHighlighting({
theme: {
light: "github-light",
dark: "github-dark",
},
keepBackground: false,
}),
Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),
Plugin.GitHubFlavoredMarkdown(),
Plugin.TableOfContents(),
Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
Plugin.Description(),
Plugin.Latex({ renderEngine: "katex" }),
Plugin.ClickableImages(),
Plugin.Carousel(),
],