Credits

Change sorting order of “Explorer”

Reference

quartz.layout.ts
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

quartz.layout.ts
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

  1. Add carousel.ts to plugins/transformers folder - source code
  2. Update plugins/transformers/index.ts to export carousel - source code
  3. Add carousel.inline.ts to components/scripts folder (for behavior) - source code
  4. Add carousel.inline.scss to components/styles folder (for styling) - source code
  5. 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

plugins/transformers/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

components/scripts/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

components/styles/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

plugins/transformers/index.ts
...
export { RoamFlavoredMarkdown } from "./roam"
export { ClickableImages } from "./clickableImages"
export { Carousel } from "./carousel"

quartz.config.ts

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(),
    ],