From 8168dc6951f5320ef7490f5acfbfeacc48fb2aa9 Mon Sep 17 00:00:00 2001 From: Ben Lubas Date: Thu, 22 Jan 2026 14:51:44 -0500 Subject: [PATCH] feat(html): section-collapse metadata Collapse (hide) sections (delimited by headings) by clicking a chevron next to the heading. Expand/collapse all with an option in the codetools menu --- src/command/render/codetools.ts | 60 +++++++++++++++---- src/config/constants.ts | 4 ++ src/config/types.ts | 5 ++ src/format/html/format-html-shared.ts | 2 + src/format/html/format-html-types.ts | 1 + src/format/html/format-html.ts | 16 +++++ src/resources/editor/tools/vs-code.mjs | 2 + .../formats/html/sectionCollapse.css | 22 +++++++ .../html/templates/quarto-html-after-body.ejs | 48 ++++++++++++++- src/resources/language/_language.yml | 2 + src/resources/schema/definitions.yml | 2 + src/resources/schema/document-code.yml | 1 + src/resources/schema/document-options.yml | 8 +++ src/resources/schema/json-schemas.json | 8 ++- src/resources/types/schema-types.ts | 2 + 15 files changed, 169 insertions(+), 14 deletions(-) create mode 100644 src/resources/formats/html/sectionCollapse.css diff --git a/src/command/render/codetools.ts b/src/command/render/codetools.ts index d7a7cee1d5f..2fee310ae6e 100644 --- a/src/command/render/codetools.ts +++ b/src/command/render/codetools.ts @@ -14,8 +14,10 @@ import { Format } from "../../config/types.ts"; import { kCodeTools, kCodeToolsHideAllCode, + kCodeToolsHideAllSections, kCodeToolsMenuCaption, kCodeToolsShowAllCode, + kCodeToolsShowAllSections, kCodeToolsSourceCode, kCodeToolsViewSource, kKeepSource, @@ -29,9 +31,12 @@ import { isHtmlOutput } from "../../config/format.ts"; import { executionEngineCanKeepSource } from "../../execute/engine-info.ts"; import { formatHasBootstrap } from "../../format/html/format-html-info.ts"; import { withTiming } from "../../core/timing.ts"; +import { kSectionCollapse } from "../../format/html/format-html-shared.ts"; const kHideAllCodeLinkId = "quarto-hide-all-code"; const kShowAllCodeLinkId = "quarto-show-all-code"; +const kShowAllSectionsLinkId = "quarto-show-all-sections"; +const kHideAllSectionsLinkId = "quarto-hide-all-sections"; const kViewSourceLinkId = "quarto-view-source"; const kEmbeddedSourceClass = "quarto-embedded-source-code"; export const kEmbeddedSourceModalId = kEmbeddedSourceClass + "-modal"; @@ -140,7 +145,7 @@ export function codeToolsPostprocessor(format: Format) { if (formatHasCodeTools(format)) { // resolve what sort of code tools we will present const codeTools = resolveCodeTools(format, doc); - if (codeTools.source || codeTools.toggle) { + if (codeTools.source || codeTools.toggle || codeTools.section) { const title = doc.querySelector("#title-block-header h1"); const header = title !== null ? (title as Element).parentElement @@ -178,7 +183,7 @@ export function codeToolsPostprocessor(format: Format) { header!.prepend(titleDiv); } - if (codeTools.toggle) { + if (codeTools.toggle || codeTools.section) { button.setAttribute("id", kCodeToolsMenuButtonId); button.classList.add("dropdown-toggle"); button.setAttribute("data-bs-toggle", "dropdown"); @@ -206,14 +211,29 @@ export function codeToolsPostprocessor(format: Format) { li.appendChild(hr); ul.appendChild(li); }; - addListItem( - kShowAllCodeLinkId, - format.language[kCodeToolsShowAllCode]!, - ); - addListItem( - kHideAllCodeLinkId, - format.language[kCodeToolsHideAllCode]!, - ); + if (codeTools.toggle) { + addListItem( + kShowAllCodeLinkId, + format.language[kCodeToolsShowAllCode]!, + ); + addListItem( + kHideAllCodeLinkId, + format.language[kCodeToolsHideAllCode]!, + ); + } + if (codeTools.section) { + if (codeTools.toggle) { + addDivider(); + } + addListItem( + kShowAllSectionsLinkId, + format.language[kCodeToolsShowAllSections]!, + ); + addListItem( + kHideAllSectionsLinkId, + format.language[kCodeToolsHideAllSections]!, + ); + } if (codeTools.source) { addDivider(); const vsLi = addListItem( @@ -323,6 +343,7 @@ interface CodeTools { source: boolean | string; toggle: boolean; caption: string; + section: boolean; } function resolveCodeTools(format: Format, doc: Document): CodeTools { @@ -343,6 +364,11 @@ function resolveCodeTools(format: Format, doc: Document): CodeTools { caption: typeof codeTools === "boolean" ? kCodeCaption : codeTools?.caption || kCodeCaption, + section: typeof codeTools === "boolean" + ? codeTools + : codeTools?.section !== undefined + ? codeTools?.section + : true, }; // if we have request source without an external url, @@ -362,6 +388,20 @@ function resolveCodeTools(format: Format, doc: Document): CodeTools { codeToolsResolved.toggle = !!codeDetails || !!codeHidden; } + // if we have requested section, make sure there are things to collapse + if (codeToolsResolved.section === true) { + codeToolsResolved.section = !!format.metadata[kSectionCollapse]; + } + + // Change the default caption based on what tools are shown + if ( + codeToolsResolved.section && + (codeTools === true || + (typeof codeTools !== "boolean" && codeTools?.caption === undefined)) + ) { + codeToolsResolved.caption = "Tools"; + } + // return resolved return codeToolsResolved; } diff --git a/src/config/constants.ts b/src/config/constants.ts index 56e75b1d823..abdee7b3193 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -280,6 +280,8 @@ export const kCodeLines = "code-lines"; export const kCodeToolsMenuCaption = "code-tools-menu-caption"; export const kCodeToolsShowAllCode = "code-tools-show-all-code"; export const kCodeToolsHideAllCode = "code-tools-hide-all-code"; +export const kCodeToolsShowAllSections = "code-tools-show-all-sections"; +export const kCodeToolsHideAllSections = "code-tools-hide-all-sections"; export const kCodeToolsViewSource = "code-tools-view-source"; export const kCodeToolsSourceCode = "code-tools-source-code"; export const kSearchNoResultsText = "search-no-results-text"; @@ -410,6 +412,8 @@ export const kLanguageDefaultsKeys = [ kCodeToolsMenuCaption, kCodeToolsShowAllCode, kCodeToolsHideAllCode, + kCodeToolsHideAllSections, + kCodeToolsShowAllSections, kCodeToolsViewSource, kCodeToolsSourceCode, kToolsShare, diff --git a/src/config/types.ts b/src/config/types.ts index 09daafe951d..6216c08181c 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -34,8 +34,10 @@ import { kCodeSummary, kCodeTools, kCodeToolsHideAllCode, + kCodeToolsHideAllSections, kCodeToolsMenuCaption, kCodeToolsShowAllCode, + kCodeToolsShowAllSections, kCodeToolsSourceCode, kCodeToolsViewSource, kColumns, @@ -481,6 +483,7 @@ export interface FormatRender { source?: boolean; toggle?: boolean; caption?: string; + section?: boolean; }; [kTblColwidths]?: "auto" | boolean | number[]; [kShortcodes]?: string[]; @@ -680,6 +683,8 @@ export interface FormatLanguage { [kCodeToolsMenuCaption]?: string; [kCodeToolsShowAllCode]?: string; [kCodeToolsHideAllCode]?: string; + [kCodeToolsShowAllSections]?: string; + [kCodeToolsHideAllSections]?: string; [kCodeToolsViewSource]?: string; [kCodeToolsSourceCode]?: string; [kToolsDownload]?: string; diff --git a/src/format/html/format-html-shared.ts b/src/format/html/format-html-shared.ts index dbba131dc03..5e53efd2172 100644 --- a/src/format/html/format-html-shared.ts +++ b/src/format/html/format-html-shared.ts @@ -58,6 +58,8 @@ export const kUtterances = "utterances"; export const kGiscus = "giscus"; export const kAxe = "axe"; +export const kSectionCollapse = "section-collapse"; + export const kGiscusRepoId = "repo-id"; export const kGiscusCategoryId = "category-id"; diff --git a/src/format/html/format-html-types.ts b/src/format/html/format-html-types.ts index 3cf609e9d6b..92c5e560a8a 100644 --- a/src/format/html/format-html-types.ts +++ b/src/format/html/format-html-types.ts @@ -15,6 +15,7 @@ export interface HtmlFormatFeatureDefaults { figResponsive?: boolean; codeAnnotations?: boolean; hoverXrefs?: boolean; + sectionCollapse?: boolean | 'closed'; } export interface HtmlFormatTippyOptions { diff --git a/src/format/html/format-html.ts b/src/format/html/format-html.ts index 305de562c8c..15e641b213f 100644 --- a/src/format/html/format-html.ts +++ b/src/format/html/format-html.ts @@ -87,6 +87,7 @@ import { kXrefsHover, quartoBaseLayer, quartoGlobalCssVariableRules, + kSectionCollapse, } from "./format-html-shared.ts"; import { kSiteUrl, @@ -327,6 +328,13 @@ export async function htmlFormatExtras( options.hoverFootnotes = format.metadata[kFootnotesHover] || false; } + if (featureDefaults.sectionCollapse) { + options.sectionCollapse = format.metadata[kSectionCollapse] !== false; + } else { + options.sectionCollapse = format.metadata[kSectionCollapse] || false; + } + options.sectionCollapseClosed = format.metadata[kSectionCollapse] === 'closed'; + // Books don't currently support hover xrefs (since the content to preview in the xref // is likely to be on another page and we don't want to do a full fetch of that page // to get the preview) @@ -413,6 +421,14 @@ export async function htmlFormatExtras( }); } + // sectionCollapse if required + if (options.sectionCollapse) { + stylesheets.push({ + name: "sectionCollapse.css", + path: formatResourcePath("html", "sectionCollapse.css"), + }) + } + // tippy if required if (options.tippy) { scripts.push({ diff --git a/src/resources/editor/tools/vs-code.mjs b/src/resources/editor/tools/vs-code.mjs index 393d87f6d41..d651fd21be2 100644 --- a/src/resources/editor/tools/vs-code.mjs +++ b/src/resources/editor/tools/vs-code.mjs @@ -10344,6 +10344,8 @@ var require_yaml_intelligence_resources = __commonJS({ "section-title-appendices": "string", "code-summary": "string", "code-tools-menu-caption": "string", + "code-tools-show-all-sections": "string", + "code-tools-hide-all-sections": "string", "code-tools-show-all-code": "string", "code-tools-hide-all-code": "string", "code-tools-view-source": "string", diff --git a/src/resources/formats/html/sectionCollapse.css b/src/resources/formats/html/sectionCollapse.css new file mode 100644 index 00000000000..81eaa7ca1ac --- /dev/null +++ b/src/resources/formats/html/sectionCollapse.css @@ -0,0 +1,22 @@ +.sc-carret { + font-family: "Courier New", Courier, monospace; + opacity: 0.7; + float: right; + cursor: pointer; + transform: rotate(-90deg); + transition: transform 0.15s ease; + user-select: none; + + &:hover { + opacity: 0.8; + } + + &.closed { + transform: rotate(0deg); + } +} + +/* section:not(.cell-output):has(h1, h2, h3, h4, h5, h6 > div.sc-carret.closed) > :not(:first-child) */ +section:not(.cell-output):has(> :is(h1,h2,h3,h4,h5,h6) > div.sc-carret.closed)> :not(:first-child) { + display: none; +} diff --git a/src/resources/formats/html/templates/quarto-html-after-body.ejs b/src/resources/formats/html/templates/quarto-html-after-body.ejs index 55fade9537c..bb099e4b424 100644 --- a/src/resources/formats/html/templates/quarto-html-after-body.ejs +++ b/src/resources/formats/html/templates/quarto-html-after-body.ejs @@ -1,4 +1,4 @@ -<% if (darkMode !== undefined || tabby || anchors || copyCode || codeTools || tippy || hoverFootnotes || hoverCitations || linkExternalIcon || linkExternalNewwindow || hoverXrefs) { %> +<% if (darkMode !== undefined || tabby || anchors || copyCode || codeTools || tippy || hoverFootnotes || hoverCitations || linkExternalIcon || linkExternalNewwindow || hoverXrefs || sectionCollapse) { %> <% } %> - \ No newline at end of file + diff --git a/src/resources/language/_language.yml b/src/resources/language/_language.yml index f2a7f22803f..c0fd1e05839 100644 --- a/src/resources/language/_language.yml +++ b/src/resources/language/_language.yml @@ -46,6 +46,8 @@ code-summary: "Code" code-tools-menu-caption: "Code" code-tools-show-all-code: "Show All Code" code-tools-hide-all-code: "Hide All Code" +code-tools-show-all-sections: "Expand Sections" +code-tools-hide-all-sections: "Collapse Sections" code-tools-view-source: "View Source" code-tools-source-code: "Source Code" diff --git a/src/resources/schema/definitions.yml b/src/resources/schema/definitions.yml index e1240ac3689..f3412895146 100644 --- a/src/resources/schema/definitions.yml +++ b/src/resources/schema/definitions.yml @@ -1377,6 +1377,8 @@ section-title-appendices: string code-summary: string code-tools-menu-caption: string + code-tools-show-all-sections: string + code-tools-hide-all-sections: string code-tools-show-all-code: string code-tools-hide-all-code: string code-tools-view-source: string diff --git a/src/resources/schema/document-code.yml b/src/resources/schema/document-code.yml index 47593cdec69..30b3596d917 100644 --- a/src/resources/schema/document-code.yml +++ b/src/resources/schema/document-code.yml @@ -61,6 +61,7 @@ - string toggle: boolean caption: string + section: boolean default: false description: short: "Include a code tools menu (for hiding and showing code)." diff --git a/src/resources/schema/document-options.yml b/src/resources/schema/document-options.yml index 1ec0009661a..d12755e26b1 100644 --- a/src/resources/schema/document-options.yml +++ b/src/resources/schema/document-options.yml @@ -70,6 +70,14 @@ formats: [$html-doc] description: Enables hover over a section title to see an anchor link. +- name: section-collapse + schema: + enum: [true, false, "closed"] + default: false + tags: + formats: [$html-doc] + description: Add a button that collapses the content below a header + - name: tabsets schema: boolean default: true diff --git a/src/resources/schema/json-schemas.json b/src/resources/schema/json-schemas.json index 887f5c78248..5b824c81aa1 100644 --- a/src/resources/schema/json-schemas.json +++ b/src/resources/schema/json-schemas.json @@ -1699,6 +1699,12 @@ "code-tools-menu-caption": { "type": "string" }, + "code-tools-show-all-sections": { + "type": "string" + }, + "code-tools-hide-all-sections": { + "type": "string" + }, "code-tools-show-all-code": { "type": "string" }, @@ -4354,4 +4360,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/resources/types/schema-types.ts b/src/resources/types/schema-types.ts index ef248cf5459..ad13b149566 100644 --- a/src/resources/types/schema-types.ts +++ b/src/resources/types/schema-types.ts @@ -682,6 +682,8 @@ export type FormatLanguage = { "code-tools-menu-caption"?: string; "code-tools-show-all-code"?: string; "code-tools-hide-all-code"?: string; + "code-tools-show-all-sections"?: string; + "code-tools-hide-all-sections"?: string; "code-tools-view-source"?: string; "code-tools-source-code"?: string; "search-no-results-text"?: string;