Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/changelog-1.11.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
43 changes: 42 additions & 1 deletion src/resources/formats/html/bootstrap/_bootstrap-rules.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
169 changes: 169 additions & 0 deletions tests/docs/playwright/html/callout-code-copy-button.qmd
Original file line number Diff line number Diff line change
@@ -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"
```

:::
Original file line number Diff line number Diff line change
@@ -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<Geometry> {
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");
});
});
Loading