Skip to content

Commit ee1abc4

Browse files
committed
feat: Add support for tabset fragments in Reveal.js presentations
1 parent 3b905a2 commit ee1abc4

4 files changed

Lines changed: 180 additions & 1 deletion

File tree

news/changelog-1.9.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ All changes included in 1.9:
4848
- ([#13667](https://github.com/quarto-dev/quarto-cli/issues/13667)): Fix LaTeX compilation error with Python error output containing caret characters.
4949
- ([#13730](https://github.com/quarto-dev/quarto-cli/issues/13730)): Fix TinyTeX detection when `~/.TinyTeX/` directory exists without binaries. Quarto now verifies that the bin directory and tlmgr binary exist before reporting TinyTeX as available, allowing proper fallback to system PATH installations.
5050

51+
### `revealjs`
52+
53+
- ([#13712](https://github.com/quarto-dev/quarto-cli/issues/13712)): Add support for tabset fragments in Reveal.js presentations, allowing content within tabs to be revealed incrementally during presentations.
54+
5155
## Projects
5256

5357
### `website`

src/format/reveal/format-reveal-plugin.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,12 @@ export async function revealPluginExtras(
121121
{
122122
plugin: formatResourcePath("revealjs", join("plugins", "line-highlight")),
123123
},
124-
{ plugin: formatResourcePath("revealjs", join("plugins", "pdfexport")) },
124+
{
125+
plugin: formatResourcePath("revealjs", join("plugins", "pdfexport"))
126+
},
127+
{
128+
plugin: formatResourcePath("revealjs", join("plugins", "tabset"))
129+
},
125130
];
126131

127132
// menu plugin (enabled by default)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
name: RevealJsTabset
2+
script: tabset.js
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/**
2+
* MIT License
3+
*
4+
* Copyright (c) 2025 Mickaël Canouil
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
window.RevealJsTabset = function () {
26+
return {
27+
id: "RevealJsTabset",
28+
init: function (deck) {
29+
const TAB_SELECTOR = "ul.panel-tabset-tabby > li";
30+
const TAB_LINK_SELECTOR = "ul.panel-tabset-tabby > li a";
31+
32+
/**
33+
* Get all tab panes for a given tabset element.
34+
* @param {Element} tabset - The tabset container element
35+
* @returns {HTMLCollection} Collection of tab pane elements
36+
*/
37+
function getTabPanes(tabset) {
38+
const tabContent = tabset.querySelector(".tab-content");
39+
return tabContent ? tabContent.children : [];
40+
}
41+
42+
/**
43+
* Initialise tabset fragments on ready.
44+
* This sets up fragment indices for tab content and creates invisible
45+
* fragment triggers for tab navigation.
46+
*/
47+
deck.on("ready", function () {
48+
const tabsetSlides = document.querySelectorAll(".reveal .slides section .panel-tabset");
49+
50+
tabsetSlides.forEach(function (tabset) {
51+
const tabs = tabset.querySelectorAll(TAB_SELECTOR);
52+
const tabCount = tabs.length;
53+
if (tabCount <= 1) return;
54+
55+
const tabPanes = getTabPanes(tabset);
56+
const parentNode = tabset.parentNode;
57+
let currentIndex = 0;
58+
59+
// Process each tab
60+
for (let i = 0; i < tabCount; i++) {
61+
if (tabPanes[i]) {
62+
// Assign fragment indices to any fragments within the tab pane
63+
const fragmentsInPane = tabPanes[i].querySelectorAll(".fragment");
64+
fragmentsInPane.forEach(function (fragment) {
65+
fragment.setAttribute("data-fragment-index", currentIndex);
66+
currentIndex++;
67+
});
68+
}
69+
70+
// Create invisible fragment triggers for tab switching (except after last tab)
71+
if (i < tabCount - 1) {
72+
const fragmentDiv = document.createElement("div");
73+
fragmentDiv.className = "panel-tabset-fragment fragment";
74+
fragmentDiv.dataset.tabIndex = i + 1;
75+
fragmentDiv.setAttribute("data-fragment-index", currentIndex);
76+
fragmentDiv.style.display = "none";
77+
parentNode.appendChild(fragmentDiv);
78+
currentIndex++;
79+
}
80+
}
81+
});
82+
});
83+
84+
/**
85+
* Handle fragment shown events.
86+
* When a tabset fragment is shown, click the corresponding tab.
87+
*/
88+
deck.on("fragmentshown", function (event) {
89+
if (!event.fragment.classList.contains("panel-tabset-fragment")) return;
90+
91+
const tabIndex = parseInt(event.fragment.dataset.tabIndex, 10);
92+
const tabset = deck.getCurrentSlide().querySelector(".panel-tabset");
93+
if (!tabset) return;
94+
95+
const tabLinks = tabset.querySelectorAll(TAB_LINK_SELECTOR);
96+
if (tabLinks[tabIndex]) {
97+
tabLinks[tabIndex].click();
98+
}
99+
});
100+
101+
/**
102+
* Handle fragment hidden events.
103+
* When a tabset fragment is hidden (going backwards), click the previous tab.
104+
*/
105+
deck.on("fragmenthidden", function (event) {
106+
if (!event.fragment.classList.contains("panel-tabset-fragment")) return;
107+
108+
const tabIndex = parseInt(event.fragment.dataset.tabIndex, 10);
109+
const tabset = deck.getCurrentSlide().querySelector(".panel-tabset");
110+
if (!tabset) return;
111+
112+
const tabLinks = tabset.querySelectorAll(TAB_LINK_SELECTOR);
113+
const targetIndex = tabIndex > 0 ? tabIndex - 1 : 0;
114+
if (tabLinks[targetIndex]) {
115+
tabLinks[targetIndex].click();
116+
}
117+
});
118+
119+
/**
120+
* Handle PDF export mode.
121+
* Ensures the correct tab is visible based on fragment state.
122+
*/
123+
deck.on("pdf-ready", function () {
124+
const slides = document.querySelectorAll(".reveal .slides section");
125+
126+
slides.forEach(function (slide) {
127+
const tabset = slide.querySelector(".panel-tabset");
128+
if (!tabset) return;
129+
130+
const fragments = slide.querySelectorAll(".panel-tabset-fragment");
131+
let activeTabIndex = 0;
132+
133+
// Find the highest visible tab index
134+
fragments.forEach(function (fragment) {
135+
if (fragment.classList.contains("visible")) {
136+
const tabIndex = parseInt(fragment.dataset.tabIndex, 10);
137+
if (tabIndex > activeTabIndex) {
138+
activeTabIndex = tabIndex;
139+
}
140+
}
141+
});
142+
143+
// Update tab states
144+
const tabLinks = tabset.querySelectorAll(TAB_LINK_SELECTOR);
145+
const tabPanes = getTabPanes(tabset);
146+
const tabPanesArray = Array.from(tabPanes);
147+
148+
tabLinks.forEach(function (link, index) {
149+
const li = link.parentElement;
150+
const isActive = index === activeTabIndex;
151+
152+
li.classList.toggle("active", isActive);
153+
link.setAttribute("aria-selected", isActive ? "true" : "false");
154+
link.setAttribute("tabindex", isActive ? "0" : "-1");
155+
});
156+
157+
// Update pane visibility
158+
tabPanesArray.forEach(function (panel, index) {
159+
const isActive = index === activeTabIndex;
160+
161+
panel.classList.toggle("active", isActive);
162+
panel.style.display = isActive ? "block" : "none";
163+
});
164+
});
165+
});
166+
},
167+
};
168+
};

0 commit comments

Comments
 (0)