diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 1ab5a6c4de..e17740dfb3 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -62,6 +62,7 @@ All changes included in 1.11: - ([#12452](https://github.com/quarto-dev/quarto-cli/issues/12452)): Fix repeated HTML escaping in Google Scholar metadata, including apostrophes in titles, special characters in authors and URLs, and nested reference metadata. - ([#12650](https://github.com/quarto-dev/quarto-cli/issues/12650)): Fix markdown formatting and HTML entities in a document title appearing literally in the appendix citation, the BibTeX citation, and the Google Scholar `citation_title`. (author: @jolars) - ([#13444](https://github.com/quarto-dev/quarto-cli/issues/13444)): Fix the code copy button covering the code annotation marker on the first line of an annotated code block, which stopped the annotation from showing on hover or click (regression introduced in v1.8.15). +- ([#13446](https://github.com/quarto-dev/quarto-cli/issues/13446)): Fix the code copy button sitting above the code frame, and the extra space around the code, when a code block starts or ends a callout (regression introduced in v1.8.15). - ([#14684](https://github.com/quarto-dev/quarto-cli/issues/14684)): Add a "Skip to main content" link to Bootstrap-themed HTML output (documents, websites, books, dashboards) so keyboard users can bypass the navbar and sidebars. - ([#14973](https://github.com/quarto-dev/quarto-cli/issues/14973)): Fix Safari sometimes opening pages with a light and dark theme at the top instead of at the URL fragment target, and toggling the color scheme scrolling back to the top of the page. - ([#14976](https://github.com/quarto-dev/quarto-cli/pull/14976)): Fix KaTeX math not rendering on pages with Jupyter widgets when `html-math-method` is set with the object form (`method: katex` and `url`). diff --git a/src/resources/formats/html/bootstrap/_bootstrap-rules.scss b/src/resources/formats/html/bootstrap/_bootstrap-rules.scss index 97b0e374e1..512c1bb7ae 100644 --- a/src/resources/formats/html/bootstrap/_bootstrap-rules.scss +++ b/src/resources/formats/html/bootstrap/_bootstrap-rules.scss @@ -1522,11 +1522,52 @@ kbd, } .callout.callout-titled .callout-body > :last-child:not(.sourceCode), -.callout.callout-titled .callout-body > div > :last-child:not(.sourceCode) { +.callout.callout-titled .callout-body > div > :last-child:not(.sourceCode, .code-copy-button) { padding-bottom: 0.5rem; margin-bottom: 0; } +// Code blocks are wrapped in .code-copy-outer-scaffold, which positions the +// copy button. At a callout edge, space the scaffold with margin instead of +// padding and drop the code block's own margin, so the button stays inside +// the code frame and the gap matches a paragraph's. +.callout .callout-body-container { + > .code-copy-outer-scaffold:first-child, + > .cell:first-child > .code-copy-outer-scaffold:first-child { + > div.sourceCode { + margin-top: 0; + } + } + + > .code-copy-outer-scaffold:last-child, + > .cell:last-child > .code-copy-outer-scaffold:last-child { + > div.sourceCode { + margin-bottom: 0; + } + } +} + +.callout.callout-style-default .callout-body { + > .code-copy-outer-scaffold:first-child, + > .cell:first-child:has(> .code-copy-outer-scaffold:first-child) { + padding-top: 0; + margin-top: 0.5rem; + } +} + +.callout.callout-titled .callout-body { + > .code-copy-outer-scaffold:last-child, + > .cell:last-child:has(> .code-copy-outer-scaffold:last-child) { + padding-bottom: 0; + margin-bottom: 0.5rem; + } + + // Undoes the `> div > :last-child` padding above for code ending a cell. + > .cell:last-child > .code-copy-outer-scaffold:last-child { + padding-bottom: 0; + } +} + .callout:not(.callout-titled) .callout-body > :first-child, .callout:not(.callout-titled) .callout-body > div > :first-child { margin-top: 0.25rem; diff --git a/tests/docs/playwright/html/callout-code-copy-button.qmd b/tests/docs/playwright/html/callout-code-copy-button.qmd new file mode 100644 index 0000000000..fab7ae78c5 --- /dev/null +++ b/tests/docs/playwright/html/callout-code-copy-button.qmd @@ -0,0 +1,169 @@ +--- +title: "Code copy button in callouts" +format: html +code-annotations: hover +--- + +## Plain + +::: {#plain} + +```bash +echo "plain" +``` + +::: + +## Plain, annotated + +::: {#plain-annotated} + +```python +import polars as pl # <1> +``` + +1. Import polars. + +::: + +## Titled, paragraph only + +::: {#titled-paragraph .callout-note} + +## Titled, paragraph only + +A paragraph. + +::: + +## Titled, code first + +::: {#titled-first .callout-note} + +## Titled, code first + +```bash +echo "first" +``` + +A paragraph. + +::: + +## Titled, code last + +::: {#titled-last .callout-note} + +## Titled, code last + +A paragraph. + +```bash +echo "last" +``` + +::: + +## Collapsible, code first + +::: {#collapse-first .callout-note collapse="false"} + +## Collapsible, code first + +```bash +echo "first" +``` + +A paragraph. + +::: + +## Titled, cell first + +::: {#titled-cell-first .callout-note} + +## Titled, cell first + +::: {.cell} + +```{.r .cell-code} +1 + 1 +``` + +::: + +A paragraph. + +::: + +## Titled, cell last + +::: {#titled-cell-last .callout-note} + +## Titled, cell last + +A paragraph. + +::: {.cell} + +```{.r .cell-code} +1 + 1 +``` + +::: + +::: + +## Titled, annotated code first + +::: {#titled-annotated .callout-note} + +## Titled, annotated code first + +```python +import polars as pl # <1> +``` + +1. Import polars. + +::: + +## Simple, paragraph only + +::: {#simple-paragraph .callout-note appearance="simple"} + +A paragraph. + +::: + +## Simple, code first + +::: {#simple-first .callout-note appearance="simple"} + +```bash +echo "first" +``` + +A paragraph. + +::: + +## Minimal, paragraph only + +::: {#minimal-paragraph .callout-note appearance="minimal"} + +A paragraph. + +::: + +## Minimal, code last + +::: {#minimal-last .callout-note appearance="minimal"} + +A paragraph. + +```bash +echo "last" +``` + +::: diff --git a/tests/integration/playwright/tests/html-callout-code-copy-button.spec.ts b/tests/integration/playwright/tests/html-callout-code-copy-button.spec.ts new file mode 100644 index 0000000000..1bcd9a0d8e --- /dev/null +++ b/tests/integration/playwright/tests/html-callout-code-copy-button.spec.ts @@ -0,0 +1,121 @@ +import { expect, test, type Page } from "@playwright/test"; +import { getUrl } from "../src/utils"; + +// Regression tests for #13446 and #13238. Code blocks are wrapped in +// div.code-copy-outer-scaffold, which holds div.sourceCode and the copy +// button. The callout first/last-child rules must treat that wrapper like a +// code block: the button stays inside the code frame, and code at the edge of +// a callout gets the same gap as a paragraph there. + +interface Geometry { + // Copy button top minus div.sourceCode top + buttonOffset: number | null; + buttonHeight: number | null; + // Visible top of the first block below the header (or callout top) + topGap: number; + // Callout bottom minus visible bottom of the last block + bottomGap: number; +} + +async function geometry(page: Page, id: string): Promise { + return await page.evaluate((id) => { + const el = document.getElementById(id)!; + // Code frame is the div.sourceCode border box; text is a paragraph's + // content box. + const visible = (e: Element) => { + const r = e.getBoundingClientRect(); + if (e.tagName !== "P") return { top: r.top, bottom: r.bottom }; + const cs = getComputedStyle(e); + return { + top: r.top + parseFloat(cs.paddingTop), + bottom: r.bottom - parseFloat(cs.paddingBottom), + }; + }; + const header = el.querySelector(".callout-header"); + const ref = header && getComputedStyle(header).display !== "none" + ? header.getBoundingClientRect().bottom + : el.getBoundingClientRect().top; + const blocks = Array.from( + el.querySelectorAll("div.sourceCode, .callout-body-container > p"), + ); + const div = el.querySelector("div.sourceCode"); + const button = el.querySelector(".code-copy-button"); + return { + buttonOffset: div && button + ? button.getBoundingClientRect().top - div.getBoundingClientRect().top + : null, + buttonHeight: button ? button.getBoundingClientRect().height : null, + topGap: visible(blocks[0]).top - ref, + bottomGap: el.getBoundingClientRect().bottom - + visible(blocks[blocks.length - 1]).bottom, + }; + }, id); +} + +// Code callouts, with the block outside a callout their copy button is +// compared with, and the paragraph-only callout of the same appearance they +// are compared with at the edge where the code sits. +const cases = [ + { id: "titled-first", button: "plain", edge: "top", reference: "titled-paragraph" }, + { id: "titled-last", button: "plain", edge: "bottom", reference: "titled-paragraph" }, + { id: "titled-cell-first", button: "plain", edge: "top", reference: "titled-paragraph" }, + { id: "titled-cell-last", button: "plain", edge: "bottom", reference: "titled-paragraph" }, + { id: "titled-annotated", button: "plain-annotated", edge: "top", reference: "titled-paragraph" }, + { id: "collapse-first", button: "plain", edge: null, reference: null }, + { id: "simple-first", button: "plain", edge: "top", reference: "simple-paragraph" }, + { id: "minimal-last", button: "plain", edge: "bottom", reference: "minimal-paragraph" }, +] as const; + +test.describe("Code copy button in callouts", () => { + test.beforeEach(async ({ page }) => { + await page.goto(getUrl("html/callout-code-copy-button.html"), { + waitUntil: "load", + }); + }); + + for (const { id, button, edge, reference } of cases) { + test(`${id}: copy button sits inside the code frame`, async ({ page }) => { + const plain = await geometry(page, button); + const code = await geometry(page, id); + expect(code.buttonOffset).not.toBeNull(); + expect(Math.abs(code.buttonOffset! - plain.buttonOffset!)) + .toBeLessThanOrEqual(1); + expect(Math.abs(code.buttonHeight! - plain.buttonHeight!)) + .toBeLessThanOrEqual(1); + }); + + if (edge && reference) { + test(`${id}: code at the ${edge} edge is spaced like a paragraph`, async ({ page }) => { + const code = await geometry(page, id); + const para = await geometry(page, reference); + const gap = edge === "top" ? "topGap" : "bottomGap"; + expect(Math.abs(code[gap] - para[gap])).toBeLessThanOrEqual(1); + }); + } + } + + // Inside the code frame, the button of an annotated first line must still + // leave the annotation marker hoverable, as it does outside a callout. + test("titled-annotated: copy button does not cover the marker", async ({ page }) => { + const anchor = page.locator( + "div#titled-annotated button.code-annotation-anchor", + ); + await expect(anchor).toHaveCount(1); + // elementFromPoint only sees the viewport. + await anchor.scrollIntoViewIfNeeded(); + const box = (await anchor.boundingBox())!; + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + + // Hovering the marker also reveals the copy button. + await page.mouse.move(x, y); + const hit = await page.evaluate( + ([px, py]) => + document.elementFromPoint(px, py)?.closest( + ".code-annotation-anchor, .code-copy-button", + )?.className ?? null, + [x, y], + ); + expect(hit).toContain("code-annotation-anchor"); + }); +});