Stephen Tse
2025-04-26 4bd714b7be4a5d891ab187a94e64e537cb43809e
fix(callout): Grid-based callout collapsible animation (#1944)

* Fixed broken nested callout maxHeight calculation

* Implemented grid-based callout collapsible
3 files modified
82 ■■■■ changed files
quartz/components/scripts/callout.inline.ts 29 ●●●● patch | view | raw | blame | history
quartz/plugins/transformers/ofm.ts 39 ●●●●● patch | view | raw | blame | history
quartz/styles/callouts.scss 14 ●●●● patch | view | raw | blame | history
quartz/components/scripts/callout.inline.ts
@@ -1,25 +1,10 @@
function toggleCallout(this: HTMLElement) {
  const outerBlock = this.parentElement!
  outerBlock.classList.toggle("is-collapsed")
  const content = outerBlock.getElementsByClassName("callout-content")[0] as HTMLElement
  if (!content) return
  const collapsed = outerBlock.classList.contains("is-collapsed")
  const height = collapsed ? this.scrollHeight : outerBlock.scrollHeight
  outerBlock.style.maxHeight = height + "px"
  // walk and adjust height of all parents
  let current = outerBlock
  let parent = outerBlock.parentElement
  while (parent) {
    if (!parent.classList.contains("callout")) {
      return
    }
    const collapsed = parent.classList.contains("is-collapsed")
    const height = collapsed ? parent.scrollHeight : parent.scrollHeight + current.scrollHeight
    parent.style.maxHeight = height + "px"
    current = parent
    parent = parent.parentElement
  }
  content.style.gridTemplateRows = collapsed ? "0fr" : "1fr"
}
function setupCallout() {
@@ -27,15 +12,15 @@
    `callout is-collapsible`,
  ) as HTMLCollectionOf<HTMLElement>
  for (const div of collapsible) {
    const title = div.firstElementChild
    if (!title) continue
    const title = div.getElementsByClassName("callout-title")[0] as HTMLElement
    const content = div.getElementsByClassName("callout-content")[0] as HTMLElement
    if (!title || !content) continue
    title.addEventListener("click", toggleCallout)
    window.addCleanup(() => title.removeEventListener("click", toggleCallout))
    const collapsed = div.classList.contains("is-collapsed")
    const height = collapsed ? title.scrollHeight : div.scrollHeight
    div.style.maxHeight = height + "px"
    content.style.gridTemplateRows = collapsed ? "0fr" : "1fr"
  }
}
quartz/plugins/transformers/ofm.ts
@@ -464,6 +464,30 @@
                  })
                }
                // For the rest of the MD callout elements other than the title, wrap them with
                // two nested HTML <div>s (use some hacked mdhast component to achieve this) of
                // class `callout-content` and `callout-content-inner` respectively for
                // grid-based collapsible animation.
                if (calloutContent.length > 0) {
                  node.children = [
                    node.children[0],
                    {
                      data: { hProperties: { className: ["callout-content"] }, hName: "div" },
                      type: "blockquote",
                      children: [
                        {
                          data: {
                            hProperties: { className: ["callout-content-inner"] },
                            hName: "div",
                          },
                          type: "blockquote",
                          children: [...calloutContent],
                        },
                      ],
                    },
                  ]
                }
                // replace first line of blockquote with title and rest of the paragraph text
                node.children.splice(0, 1, ...blockquoteContent)
@@ -485,21 +509,6 @@
                    "data-callout-metadata": calloutMetaData,
                  },
                }
                // Add callout-content class to callout body if it has one.
                if (calloutContent.length > 0) {
                  const contentData: BlockContent | DefinitionContent = {
                    data: {
                      hProperties: {
                        className: "callout-content",
                      },
                      hName: "div",
                    },
                    type: "blockquote",
                    children: [...calloutContent],
                  }
                  node.children = [node.children[0], contentData]
                }
              }
            })
          }
quartz/styles/callouts.scss
@@ -7,11 +7,19 @@
  border-radius: 5px;
  padding: 0 1rem;
  overflow-y: hidden;
  transition: max-height 0.3s ease;
  box-sizing: border-box;
  & > .callout-content > :first-child {
    margin-top: 0;
  & > .callout-content {
    display: grid;
    transition: grid-template-rows 0.3s ease;
    & > .callout-content-inner {
      overflow: hidden;
      & > :first-child {
        margin-top: 0;
      }
    }
  }
  --callout-icon-note: url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="2" x2="22" y2="6"></line><path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"></path></svg>');