Skip to content

Commit 48b6c55

Browse files
authored
Keep the code copy button inside code blocks at callout edges (#15138)
A code block that starts or ends a callout had its copy button above the code frame, with more space than a paragraph at the same edge. Since #13024 wrapped code blocks in a scaffold element, the callout edge rules applied to the scaffold instead of the code. At a callout edge the scaffold now takes its spacing as margin, so the button sits inside the code frame and the code gets the same gap as a paragraph. - Use margin instead of padding for code at a callout edge, including cells whose edge child is code - Exclude the copy button from the callout last-child spacing rule - Hit-test annotated code block markers in callouts (#13444), which needs #15136 merged first - Titled callouts now leave a gap between the header and the code frame, unlike the flush layout in 1.7 See #13238 for the broader padding-to-margin change, which this does not include, and #15137 for the same offset in column-grid sections. Fixes #13446
1 parent 67893c6 commit 48b6c55

4 files changed

Lines changed: 333 additions & 1 deletion

File tree

‎news/changelog-1.11.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ All changes included in 1.11:
6262
- ([#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.
6363
- ([#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)
6464
- ([#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).
65+
- ([#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).
6566
- ([#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.
6667
- ([#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.
6768
- ([#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`).

‎src/resources/formats/html/bootstrap/_bootstrap-rules.scss‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1522,11 +1522,52 @@ kbd,
15221522
}
15231523

15241524
.callout.callout-titled .callout-body > :last-child:not(.sourceCode),
1525-
.callout.callout-titled .callout-body > div > :last-child:not(.sourceCode) {
1525+
.callout.callout-titled .callout-body > div > :last-child:not(.sourceCode, .code-copy-button) {
15261526
padding-bottom: 0.5rem;
15271527
margin-bottom: 0;
15281528
}
15291529

1530+
// Code blocks are wrapped in .code-copy-outer-scaffold, which positions the
1531+
// copy button. At a callout edge, space the scaffold with margin instead of
1532+
// padding and drop the code block's own margin, so the button stays inside
1533+
// the code frame and the gap matches a paragraph's.
1534+
.callout .callout-body-container {
1535+
> .code-copy-outer-scaffold:first-child,
1536+
> .cell:first-child > .code-copy-outer-scaffold:first-child {
1537+
> div.sourceCode {
1538+
margin-top: 0;
1539+
}
1540+
}
1541+
1542+
> .code-copy-outer-scaffold:last-child,
1543+
> .cell:last-child > .code-copy-outer-scaffold:last-child {
1544+
> div.sourceCode {
1545+
margin-bottom: 0;
1546+
}
1547+
}
1548+
}
1549+
1550+
.callout.callout-style-default .callout-body {
1551+
> .code-copy-outer-scaffold:first-child,
1552+
> .cell:first-child:has(> .code-copy-outer-scaffold:first-child) {
1553+
padding-top: 0;
1554+
margin-top: 0.5rem;
1555+
}
1556+
}
1557+
1558+
.callout.callout-titled .callout-body {
1559+
> .code-copy-outer-scaffold:last-child,
1560+
> .cell:last-child:has(> .code-copy-outer-scaffold:last-child) {
1561+
padding-bottom: 0;
1562+
margin-bottom: 0.5rem;
1563+
}
1564+
1565+
// Undoes the `> div > :last-child` padding above for code ending a cell.
1566+
> .cell:last-child > .code-copy-outer-scaffold:last-child {
1567+
padding-bottom: 0;
1568+
}
1569+
}
1570+
15301571
.callout:not(.callout-titled) .callout-body > :first-child,
15311572
.callout:not(.callout-titled) .callout-body > div > :first-child {
15321573
margin-top: 0.25rem;
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
---
2+
title: "Code copy button in callouts"
3+
format: html
4+
code-annotations: hover
5+
---
6+
7+
## Plain
8+
9+
::: {#plain}
10+
11+
```bash
12+
echo "plain"
13+
```
14+
15+
:::
16+
17+
## Plain, annotated
18+
19+
::: {#plain-annotated}
20+
21+
```python
22+
import polars as pl # <1>
23+
```
24+
25+
1. Import polars.
26+
27+
:::
28+
29+
## Titled, paragraph only
30+
31+
::: {#titled-paragraph .callout-note}
32+
33+
## Titled, paragraph only
34+
35+
A paragraph.
36+
37+
:::
38+
39+
## Titled, code first
40+
41+
::: {#titled-first .callout-note}
42+
43+
## Titled, code first
44+
45+
```bash
46+
echo "first"
47+
```
48+
49+
A paragraph.
50+
51+
:::
52+
53+
## Titled, code last
54+
55+
::: {#titled-last .callout-note}
56+
57+
## Titled, code last
58+
59+
A paragraph.
60+
61+
```bash
62+
echo "last"
63+
```
64+
65+
:::
66+
67+
## Collapsible, code first
68+
69+
::: {#collapse-first .callout-note collapse="false"}
70+
71+
## Collapsible, code first
72+
73+
```bash
74+
echo "first"
75+
```
76+
77+
A paragraph.
78+
79+
:::
80+
81+
## Titled, cell first
82+
83+
::: {#titled-cell-first .callout-note}
84+
85+
## Titled, cell first
86+
87+
::: {.cell}
88+
89+
```{.r .cell-code}
90+
1 + 1
91+
```
92+
93+
:::
94+
95+
A paragraph.
96+
97+
:::
98+
99+
## Titled, cell last
100+
101+
::: {#titled-cell-last .callout-note}
102+
103+
## Titled, cell last
104+
105+
A paragraph.
106+
107+
::: {.cell}
108+
109+
```{.r .cell-code}
110+
1 + 1
111+
```
112+
113+
:::
114+
115+
:::
116+
117+
## Titled, annotated code first
118+
119+
::: {#titled-annotated .callout-note}
120+
121+
## Titled, annotated code first
122+
123+
```python
124+
import polars as pl # <1>
125+
```
126+
127+
1. Import polars.
128+
129+
:::
130+
131+
## Simple, paragraph only
132+
133+
::: {#simple-paragraph .callout-note appearance="simple"}
134+
135+
A paragraph.
136+
137+
:::
138+
139+
## Simple, code first
140+
141+
::: {#simple-first .callout-note appearance="simple"}
142+
143+
```bash
144+
echo "first"
145+
```
146+
147+
A paragraph.
148+
149+
:::
150+
151+
## Minimal, paragraph only
152+
153+
::: {#minimal-paragraph .callout-note appearance="minimal"}
154+
155+
A paragraph.
156+
157+
:::
158+
159+
## Minimal, code last
160+
161+
::: {#minimal-last .callout-note appearance="minimal"}
162+
163+
A paragraph.
164+
165+
```bash
166+
echo "last"
167+
```
168+
169+
:::
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { expect, test, type Page } from "@playwright/test";
2+
import { getUrl } from "../src/utils";
3+
4+
// Regression tests for #13446 and #13238. Code blocks are wrapped in
5+
// div.code-copy-outer-scaffold, which holds div.sourceCode and the copy
6+
// button. The callout first/last-child rules must treat that wrapper like a
7+
// code block: the button stays inside the code frame, and code at the edge of
8+
// a callout gets the same gap as a paragraph there.
9+
10+
interface Geometry {
11+
// Copy button top minus div.sourceCode top
12+
buttonOffset: number | null;
13+
buttonHeight: number | null;
14+
// Visible top of the first block below the header (or callout top)
15+
topGap: number;
16+
// Callout bottom minus visible bottom of the last block
17+
bottomGap: number;
18+
}
19+
20+
async function geometry(page: Page, id: string): Promise<Geometry> {
21+
return await page.evaluate((id) => {
22+
const el = document.getElementById(id)!;
23+
// Code frame is the div.sourceCode border box; text is a paragraph's
24+
// content box.
25+
const visible = (e: Element) => {
26+
const r = e.getBoundingClientRect();
27+
if (e.tagName !== "P") return { top: r.top, bottom: r.bottom };
28+
const cs = getComputedStyle(e);
29+
return {
30+
top: r.top + parseFloat(cs.paddingTop),
31+
bottom: r.bottom - parseFloat(cs.paddingBottom),
32+
};
33+
};
34+
const header = el.querySelector(".callout-header");
35+
const ref = header && getComputedStyle(header).display !== "none"
36+
? header.getBoundingClientRect().bottom
37+
: el.getBoundingClientRect().top;
38+
const blocks = Array.from(
39+
el.querySelectorAll("div.sourceCode, .callout-body-container > p"),
40+
);
41+
const div = el.querySelector("div.sourceCode");
42+
const button = el.querySelector(".code-copy-button");
43+
return {
44+
buttonOffset: div && button
45+
? button.getBoundingClientRect().top - div.getBoundingClientRect().top
46+
: null,
47+
buttonHeight: button ? button.getBoundingClientRect().height : null,
48+
topGap: visible(blocks[0]).top - ref,
49+
bottomGap: el.getBoundingClientRect().bottom -
50+
visible(blocks[blocks.length - 1]).bottom,
51+
};
52+
}, id);
53+
}
54+
55+
// Code callouts, with the block outside a callout their copy button is
56+
// compared with, and the paragraph-only callout of the same appearance they
57+
// are compared with at the edge where the code sits.
58+
const cases = [
59+
{ id: "titled-first", button: "plain", edge: "top", reference: "titled-paragraph" },
60+
{ id: "titled-last", button: "plain", edge: "bottom", reference: "titled-paragraph" },
61+
{ id: "titled-cell-first", button: "plain", edge: "top", reference: "titled-paragraph" },
62+
{ id: "titled-cell-last", button: "plain", edge: "bottom", reference: "titled-paragraph" },
63+
{ id: "titled-annotated", button: "plain-annotated", edge: "top", reference: "titled-paragraph" },
64+
{ id: "collapse-first", button: "plain", edge: null, reference: null },
65+
{ id: "simple-first", button: "plain", edge: "top", reference: "simple-paragraph" },
66+
{ id: "minimal-last", button: "plain", edge: "bottom", reference: "minimal-paragraph" },
67+
] as const;
68+
69+
test.describe("Code copy button in callouts", () => {
70+
test.beforeEach(async ({ page }) => {
71+
await page.goto(getUrl("html/callout-code-copy-button.html"), {
72+
waitUntil: "load",
73+
});
74+
});
75+
76+
for (const { id, button, edge, reference } of cases) {
77+
test(`${id}: copy button sits inside the code frame`, async ({ page }) => {
78+
const plain = await geometry(page, button);
79+
const code = await geometry(page, id);
80+
expect(code.buttonOffset).not.toBeNull();
81+
expect(Math.abs(code.buttonOffset! - plain.buttonOffset!))
82+
.toBeLessThanOrEqual(1);
83+
expect(Math.abs(code.buttonHeight! - plain.buttonHeight!))
84+
.toBeLessThanOrEqual(1);
85+
});
86+
87+
if (edge && reference) {
88+
test(`${id}: code at the ${edge} edge is spaced like a paragraph`, async ({ page }) => {
89+
const code = await geometry(page, id);
90+
const para = await geometry(page, reference);
91+
const gap = edge === "top" ? "topGap" : "bottomGap";
92+
expect(Math.abs(code[gap] - para[gap])).toBeLessThanOrEqual(1);
93+
});
94+
}
95+
}
96+
97+
// Inside the code frame, the button of an annotated first line must still
98+
// leave the annotation marker hoverable, as it does outside a callout.
99+
test("titled-annotated: copy button does not cover the marker", async ({ page }) => {
100+
const anchor = page.locator(
101+
"div#titled-annotated button.code-annotation-anchor",
102+
);
103+
await expect(anchor).toHaveCount(1);
104+
// elementFromPoint only sees the viewport.
105+
await anchor.scrollIntoViewIfNeeded();
106+
const box = (await anchor.boundingBox())!;
107+
const x = box.x + box.width / 2;
108+
const y = box.y + box.height / 2;
109+
110+
// Hovering the marker also reveals the copy button.
111+
await page.mouse.move(x, y);
112+
const hit = await page.evaluate(
113+
([px, py]) =>
114+
document.elementFromPoint(px, py)?.closest(
115+
".code-annotation-anchor, .code-copy-button",
116+
)?.className ?? null,
117+
[x, y],
118+
);
119+
expect(hit).toContain("code-annotation-anchor");
120+
});
121+
});

0 commit comments

Comments
 (0)