Skip to content

Commit e4e74fd

Browse files
committed
Keep the code copy button inside code blocks in callouts
Since code blocks are wrapped in .code-copy-outer-scaffold, the callout first/last-child rules hit the scaffold instead of div.sourceCode. The padding-top added to a first child stopped the code block's own 1em margin from collapsing, and the absolutely positioned copy button anchors to the scaffold's padding box, so in a default-style callout starting with code the button sat about 19px above the code, half outside it. The last-child rules, which meant to exempt code blocks with :not(.sourceCode), padded the scaffold too, leaving an extra gap below code at the end of a callout. The `> div > :last-child` branch also matched the button itself, the scaffold's last child, and gave it 8.5px of bottom padding. At a callout edge, the scaffold is now spaced with margin instead of padding and the inner div.sourceCode margin is dropped, for bare code blocks and for cells whose code is at that edge. The button sits 5px inside the code top, as it does outside callouts, and code at the top or bottom of a callout gets the same gap as a paragraph there. This is a visible change from 1.7, where the code frame of a titled callout sat flush against the header. The general padding-to-margin change asked for in #13238 touches non-code first children too and is left out. Fixes #13446
1 parent 4a18e9d commit e4e74fd

3 files changed

Lines changed: 283 additions & 1 deletion

File tree

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

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1522,11 +1522,41 @@ 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 > .code-copy-outer-scaffold:first-child > div.sourceCode,
1535+
.callout .callout-body-container > .cell:first-child > .code-copy-outer-scaffold:first-child > div.sourceCode {
1536+
margin-top: 0;
1537+
}
1538+
1539+
.callout .callout-body-container > .code-copy-outer-scaffold:last-child > div.sourceCode,
1540+
.callout .callout-body-container > .cell:last-child > .code-copy-outer-scaffold:last-child > div.sourceCode {
1541+
margin-bottom: 0;
1542+
}
1543+
1544+
.callout.callout-style-default .callout-body > .code-copy-outer-scaffold:first-child,
1545+
.callout.callout-style-default .callout-body > .cell:first-child:has(> .code-copy-outer-scaffold:first-child) {
1546+
padding-top: 0;
1547+
margin-top: 0.5rem;
1548+
}
1549+
1550+
.callout.callout-titled .callout-body > .code-copy-outer-scaffold:last-child,
1551+
.callout.callout-titled .callout-body > .cell:last-child:has(> .code-copy-outer-scaffold:last-child) {
1552+
padding-bottom: 0;
1553+
margin-bottom: 0.5rem;
1554+
}
1555+
1556+
.callout.callout-titled .callout-body > .cell:last-child > .code-copy-outer-scaffold:last-child {
1557+
padding-bottom: 0;
1558+
}
1559+
15301560
.callout:not(.callout-titled) .callout-body > :first-child,
15311561
.callout:not(.callout-titled) .callout-body > div > :first-child {
15321562
margin-top: 0.25rem;
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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+
## Titled, paragraph only
18+
19+
::: {#titled-paragraph .callout-note}
20+
21+
## Titled, paragraph only
22+
23+
A paragraph.
24+
25+
:::
26+
27+
## Titled, code first
28+
29+
::: {#titled-first .callout-note}
30+
31+
## Titled, code first
32+
33+
```bash
34+
echo "first"
35+
```
36+
37+
A paragraph.
38+
39+
:::
40+
41+
## Titled, code last
42+
43+
::: {#titled-last .callout-note}
44+
45+
## Titled, code last
46+
47+
A paragraph.
48+
49+
```bash
50+
echo "last"
51+
```
52+
53+
:::
54+
55+
## Collapsible, code first
56+
57+
::: {#collapse-first .callout-note collapse="false"}
58+
59+
## Collapsible, code first
60+
61+
```bash
62+
echo "first"
63+
```
64+
65+
A paragraph.
66+
67+
:::
68+
69+
## Titled, cell first
70+
71+
::: {#titled-cell-first .callout-note}
72+
73+
## Titled, cell first
74+
75+
::: {.cell}
76+
77+
```{.r .cell-code}
78+
1 + 1
79+
```
80+
81+
:::
82+
83+
A paragraph.
84+
85+
:::
86+
87+
## Titled, cell last
88+
89+
::: {#titled-cell-last .callout-note}
90+
91+
## Titled, cell last
92+
93+
A paragraph.
94+
95+
::: {.cell}
96+
97+
```{.r .cell-code}
98+
1 + 1
99+
```
100+
101+
:::
102+
103+
:::
104+
105+
## Titled, annotated code first
106+
107+
::: {#titled-annotated .callout-note}
108+
109+
## Titled, annotated code first
110+
111+
```python
112+
import polars as pl # <1>
113+
```
114+
115+
1. Import polars.
116+
117+
:::
118+
119+
## Simple, paragraph only
120+
121+
::: {#simple-paragraph .callout-note appearance="simple"}
122+
123+
A paragraph.
124+
125+
:::
126+
127+
## Simple, code first
128+
129+
::: {#simple-first .callout-note appearance="simple"}
130+
131+
```bash
132+
echo "first"
133+
```
134+
135+
A paragraph.
136+
137+
:::
138+
139+
## Minimal, paragraph only
140+
141+
::: {#minimal-paragraph .callout-note appearance="minimal"}
142+
143+
A paragraph.
144+
145+
:::
146+
147+
## Minimal, code last
148+
149+
::: {#minimal-last .callout-note appearance="minimal"}
150+
151+
A paragraph.
152+
153+
```bash
154+
echo "last"
155+
```
156+
157+
:::
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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 paragraph-only callout of the same appearance
56+
// they are compared with at the edge where the code sits.
57+
const cases = [
58+
{ id: "titled-first", edge: "top", reference: "titled-paragraph" },
59+
{ id: "titled-last", edge: "bottom", reference: "titled-paragraph" },
60+
{ id: "titled-cell-first", edge: "top", reference: "titled-paragraph" },
61+
{ id: "titled-cell-last", edge: "bottom", reference: "titled-paragraph" },
62+
{ id: "titled-annotated", edge: "top", reference: "titled-paragraph" },
63+
{ id: "collapse-first", edge: null, reference: null },
64+
{ id: "simple-first", edge: "top", reference: "simple-paragraph" },
65+
{ id: "minimal-last", edge: "bottom", reference: "minimal-paragraph" },
66+
] as const;
67+
68+
test.describe("Code copy button in callouts", () => {
69+
test.beforeEach(async ({ page }) => {
70+
await page.goto(getUrl("html/callout-code-copy-button.html"), {
71+
waitUntil: "load",
72+
});
73+
});
74+
75+
for (const { id, edge, reference } of cases) {
76+
test(`${id}: copy button sits inside the code frame`, async ({ page }) => {
77+
const plain = await geometry(page, "plain");
78+
const code = await geometry(page, id);
79+
expect(code.buttonOffset).not.toBeNull();
80+
expect(Math.abs(code.buttonOffset! - plain.buttonOffset!))
81+
.toBeLessThanOrEqual(1);
82+
expect(Math.abs(code.buttonHeight! - plain.buttonHeight!))
83+
.toBeLessThanOrEqual(1);
84+
});
85+
86+
if (edge && reference) {
87+
test(`${id}: code at the ${edge} edge is spaced like a paragraph`, async ({ page }) => {
88+
const code = await geometry(page, id);
89+
const para = await geometry(page, reference);
90+
const gap = edge === "top" ? "topGap" : "bottomGap";
91+
expect(Math.abs(code[gap] - para[gap])).toBeLessThanOrEqual(1);
92+
});
93+
}
94+
}
95+
});

0 commit comments

Comments
 (0)