diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index a60538ef04f..8cc4a081ea8 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -67,6 +67,7 @@ All changes included in 1.11: - ([#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). +- ([#13448](https://github.com/quarto-dev/quarto-cli/issues/13448)): Fix the code annotation line highlight spilling out to the left of the code block, and sitting above the gutter highlight, in `html`, `dashboard` and `revealjs` output (regression introduced in v1.8.15). (author: @mcanouil) - ([#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 728b83d262a..b89c910be5b 100644 --- a/src/resources/formats/html/bootstrap/_bootstrap-rules.scss +++ b/src/resources/formats/html/bootstrap/_bootstrap-rules.scss @@ -2163,6 +2163,7 @@ pre.code-annotation-code { } } +#code-annotation-line-highlight, #code-annotation-line-highlight-gutter { width: 100%; border-top: solid $code-annotation-higlight-color 1px; @@ -2171,15 +2172,6 @@ pre.code-annotation-code { background-color: $code-annotation-higlight-bg; } -#code-annotation-line-highlight { - margin-left: -4em; - width: calc(100% + 4em); - border-top: solid $code-annotation-higlight-color 1px; - border-bottom: solid $code-annotation-higlight-color 1px; - z-index: 2; - background-color: $code-annotation-higlight-bg; -} - code.sourceCode .code-annotation-anchor.code-annotation-active { background-color: var(--quarto-hl-normal-color, #aaaaaa); border: solid var(--quarto-hl-normal-color, #aaaaaa) 1px; 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 e1748528f61..85dec878642 100644 --- a/src/resources/formats/html/templates/quarto-html-after-body.ejs +++ b/src/resources/formats/html/templates/quarto-html-after-body.ejs @@ -544,48 +544,58 @@ return targetCell + "-" + line; }) - let top = null; - let height = null; - let parent = null; + // Each band is positioned against its own containing block, which is + // not the offsetParent of the lines, so place it from client rects. + const placeBand = (band, firstEl, lastEl, codeBlock) => { + const container = band.offsetParent; + if (container === null) { + return; + } + const containerRect = container.getBoundingClientRect(); + // Reveal.js scales slides with a transform, so convert client + // pixels back to the CSS pixels of the container. + const scale = containerRect.height / container.offsetHeight || 1; + const firstRect = firstEl.getBoundingClientRect(); + const lastRect = lastEl.getBoundingClientRect(); + const top = (firstRect.top - containerRect.top) / scale - container.clientTop + container.scrollTop; + const height = (lastRect.bottom - firstRect.top) / scale; + band.style.top = top - 2 + "px"; + band.style.height = height + 4 + "px"; + if (codeBlock) { + // Without the copy button wrapper, the containing block is wider + // than the code block, so span the code block explicitly. + const blockRect = codeBlock.getBoundingClientRect(); + band.style.left = (blockRect.left - containerRect.left) / scale - container.clientLeft + container.scrollLeft + "px"; + band.style.width = blockRect.width / scale + "px"; + } + }; + if (lineIds.length > 0) { - //compute the position of the single el (top and bottom and make a div) - const el = window.document.getElementById(lineIds[0]); - top = el.offsetTop; - height = el.offsetHeight; - parent = el.parentElement.parentElement; - - if (lineIds.length > 1) { - const lastEl = window.document.getElementById(lineIds[lineIds.length - 1]); - const bottom = lastEl.offsetTop + lastEl.offsetHeight; - height = bottom - top; + const el = window.document.getElementById(lineIds[0]); + const lastEl = window.document.getElementById(lineIds[lineIds.length - 1]); + const parent = el.parentElement.parentElement; + + // cook up a div (if necessary) and position it + let div = window.document.getElementById("code-annotation-line-highlight"); + if (div === null) { + div = window.document.createElement("div"); + div.setAttribute("id", "code-annotation-line-highlight"); + div.style.position = 'absolute'; + parent.appendChild(div); } - - if (top !== null && height !== null && parent !== null) { - // cook up a div (if necessary) and position it - let div = window.document.getElementById("code-annotation-line-highlight"); - if (div === null) { - div = window.document.createElement("div"); - div.setAttribute("id", "code-annotation-line-highlight"); - div.style.position = 'absolute'; - parent.appendChild(div); - } - div.style.top = top - 2 + "px"; - div.style.height = height + 4 + "px"; - div.style.left = 0; - - let gutterDiv = window.document.getElementById("code-annotation-line-highlight-gutter"); - if (gutterDiv === null) { - gutterDiv = window.document.createElement("div"); - gutterDiv.setAttribute("id", "code-annotation-line-highlight-gutter"); - gutterDiv.style.position = 'absolute'; - const codeCell = window.document.getElementById(targetCell); - const gutter = codeCell.querySelector('.code-annotation-gutter'); - gutter.appendChild(gutterDiv); - } - - gutterDiv.style.top = top - 2 + "px"; - gutterDiv.style.height = height + 4 + "px"; + placeBand(div, el, lastEl, parent.closest("div.sourceCode") || parent); + + let gutterDiv = window.document.getElementById("code-annotation-line-highlight-gutter"); + if (gutterDiv === null) { + gutterDiv = window.document.createElement("div"); + gutterDiv.setAttribute("id", "code-annotation-line-highlight-gutter"); + gutterDiv.style.position = 'absolute'; + const codeCell = window.document.getElementById(targetCell); + const gutter = codeCell.querySelector('.code-annotation-gutter'); + gutter.appendChild(gutterDiv); } + placeBand(gutterDiv, el, lastEl); + selectedAnnoteEl = annoteEl; } }; diff --git a/src/resources/formats/revealjs/quarto.scss b/src/resources/formats/revealjs/quarto.scss index 856a1ac51b0..5a606d75e12 100644 --- a/src/resources/formats/revealjs/quarto.scss +++ b/src/resources/formats/revealjs/quarto.scss @@ -769,22 +769,15 @@ div.cell-output-display div.pagedtable-wrapper table.table { left: auto; } +.reveal #code-annotation-line-highlight, .reveal #code-annotation-line-highlight-gutter { + box-sizing: border-box; width: 100%; border-top: solid var(--quarto-hl-co-color) 1px; border-bottom: solid var(--quarto-hl-co-color) 1px; z-index: 2; } -.reveal #code-annotation-line-highlight { - margin-left: -8em; - width: calc(100% + 4em); - border-top: solid var(--quarto-hl-co-color) 1px; - border-bottom: solid var(--quarto-hl-co-color) 1px; - z-index: 2; - margin-bottom: -2px; -} - .reveal code.sourceCode .code-annotation-anchor.code-annotation-active { background-color: var(--quarto-hl-normal-color, #aaaaaa); border: solid var(--quarto-hl-normal-color, #aaaaaa) 1px; diff --git a/tests/docs/playwright/dashboard/code-annotation-highlight.qmd b/tests/docs/playwright/dashboard/code-annotation-highlight.qmd new file mode 100644 index 00000000000..0477002b865 --- /dev/null +++ b/tests/docs/playwright/dashboard/code-annotation-highlight.qmd @@ -0,0 +1,29 @@ +--- +title: "Code annotation line highlight" +format: dashboard +code-annotations: below +--- + +## Row + +```{.python} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. + +```{.python code-line-numbers="true"} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. diff --git a/tests/docs/playwright/html/code-annotation-highlight-contrast.qmd b/tests/docs/playwright/html/code-annotation-highlight-contrast.qmd new file mode 100644 index 00000000000..876f3e76562 --- /dev/null +++ b/tests/docs/playwright/html/code-annotation-highlight-contrast.qmd @@ -0,0 +1,32 @@ +--- +title: "Code annotation line highlight contrast" +format: + html: + theme: + light: cosmo + dark: darkly + respect-user-color-scheme: true +code-annotations: below +--- + +```{.python} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. + +```{.python code-line-numbers="true"} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. diff --git a/tests/docs/playwright/html/code-annotation-highlight-no-copy.qmd b/tests/docs/playwright/html/code-annotation-highlight-no-copy.qmd new file mode 100644 index 00000000000..5f66c8e9901 --- /dev/null +++ b/tests/docs/playwright/html/code-annotation-highlight-no-copy.qmd @@ -0,0 +1,29 @@ +--- +title: "Code annotation line highlight no copy" +format: + html: + code-copy: false +code-annotations: below +--- + +```{.python} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. + +```{.python code-line-numbers="true"} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. diff --git a/tests/docs/playwright/html/code-annotation-highlight-scrolled.qmd b/tests/docs/playwright/html/code-annotation-highlight-scrolled.qmd new file mode 100644 index 00000000000..8c6db7221d9 --- /dev/null +++ b/tests/docs/playwright/html/code-annotation-highlight-scrolled.qmd @@ -0,0 +1,34 @@ +--- +title: "Code annotation line highlight in a scrolled container" +format: + html: + code-copy: false +code-annotations: below +--- + +::: {#scroll-box style="position: relative; height: 200px; overflow: auto;"} + +Filler paragraph so the code block starts below the visible area of the box. + +Filler paragraph so the code block starts below the visible area of the box. + +Filler paragraph so the code block starts below the visible area of the box. + +Filler paragraph so the code block starts below the visible area of the box. + +```{.python} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. + +Filler paragraph after the annotations. + +Filler paragraph after the annotations. + +::: diff --git a/tests/docs/playwright/html/code-annotation-highlight.qmd b/tests/docs/playwright/html/code-annotation-highlight.qmd new file mode 100644 index 00000000000..7e8fed11a11 --- /dev/null +++ b/tests/docs/playwright/html/code-annotation-highlight.qmd @@ -0,0 +1,27 @@ +--- +title: "Code annotation line highlight" +format: html +code-annotations: below +--- + +```{.python} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. + +```{.python code-line-numbers="true"} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. diff --git a/tests/docs/playwright/revealjs/code-annotation-highlight.qmd b/tests/docs/playwright/revealjs/code-annotation-highlight.qmd new file mode 100644 index 00000000000..734e720db64 --- /dev/null +++ b/tests/docs/playwright/revealjs/code-annotation-highlight.qmd @@ -0,0 +1,32 @@ +--- +title: "Code annotation line highlight" +format: revealjs +code-annotations: below +--- + +## Without line numbers + +```{.python code-line-numbers="false"} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. + + +## With line numbers + +```{.python code-line-numbers="true"} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. diff --git a/tests/integration/playwright/tests/html-code-annotation-highlight.spec.ts b/tests/integration/playwright/tests/html-code-annotation-highlight.spec.ts new file mode 100644 index 00000000000..53c7b104cfd --- /dev/null +++ b/tests/integration/playwright/tests/html-code-annotation-highlight.spec.ts @@ -0,0 +1,205 @@ +import { expect, Locator, Page, test } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { getUrl } from "../src/utils"; + +// Regression test for #13448. Selecting a code annotation draws a band over +// its lines, and a second band in the annotation gutter. Both bands must stay +// inside the code block, cover the line-number column, and line up with each +// other and with the annotated lines. + +type Box = { x: number; y: number; width: number; height: number }; + +const tolerance = 1.5; + +const boxOf = async (locator: Locator): Promise => { + const box = await locator.boundingBox(); + expect(box).not.toBeNull(); + return box!; +}; + +const annotatedLines = async (page: Page, dt: Locator): Promise => { + const cell = await dt.getAttribute("data-target-cell"); + const annotation = await dt.getAttribute("data-target-annotation"); + return page.evaluate(([cell, annotation]) => { + const span = document.querySelector( + `span[data-code-cell="${cell}"][data-code-annotation="${annotation}"]`, + )!; + const lines = span.getAttribute("data-code-lines")!.split(","); + const first = document.getElementById(`${cell}-${lines[0]}`)! + .getBoundingClientRect(); + const last = document.getElementById(`${cell}-${lines[lines.length - 1]}`)! + .getBoundingClientRect(); + return { + x: first.x, + y: first.y, + width: first.width, + height: last.bottom - first.top, + }; + }, [cell, annotation]); +}; + +// The click handlers attach on DOMContentLoaded scripts, so retry a click that +// lands before they exist. +const selectAnnotation = async (page: Page, dt: Locator) => { + await expect(async () => { + if (await page.locator("#code-annotation-line-highlight").count() === 0) { + await dt.click(); + } + await expect(page.locator("#code-annotation-line-highlight")) + .toBeVisible({ timeout: 500 }); + }).toPass(); +}; + +const expectBandsPlaced = async (page: Page, block: Locator) => { + const terms = block.locator("xpath=following::dl[1]") + .locator("dt[data-target-cell]"); + const count = await terms.count(); + expect(count).toBeGreaterThan(1); + const sourceCode = await boxOf(block); + for (let i = 0; i < count; i++) { + const dt = terms.nth(i); + await selectAnnotation(page, dt); + const band = await boxOf(page.locator("#code-annotation-line-highlight")); + const gutterBand = await boxOf( + page.locator("#code-annotation-line-highlight-gutter"), + ); + const lines = await annotatedLines(page, dt); + + // The band spans the code block, line-number column included, and does + // not overflow it. + expect(Math.abs(band.x - sourceCode.x)).toBeLessThanOrEqual(tolerance); + expect( + Math.abs(band.x + band.width - (sourceCode.x + sourceCode.width)), + ).toBeLessThanOrEqual(tolerance); + + // The band covers the annotated lines and little more. + expect(band.y).toBeLessThanOrEqual(lines.y); + expect(band.y + band.height).toBeGreaterThanOrEqual( + lines.y + lines.height, + ); + expect(lines.y - band.y).toBeLessThanOrEqual(2 + tolerance); + expect(band.y + band.height - (lines.y + lines.height)) + .toBeLessThanOrEqual(2 + tolerance); + + // The gutter band lines up with the band. + expect(Math.abs(gutterBand.y - band.y)).toBeLessThanOrEqual(tolerance); + expect(Math.abs(gutterBand.height - band.height)).toBeLessThanOrEqual( + tolerance, + ); + + await dt.click(); + await expect(page.locator("#code-annotation-line-highlight")) + .toHaveCount(0); + } +}; + +// Guards the fixture against format defaults: Reveal.js numbers code lines +// unless told otherwise, so a case can silently test the wrong layout. +const expectLineNumbers = async (block: Locator, numbered: boolean) => { + const pre = block.locator("pre.code-annotated"); + if (numbered) { + await expect(pre).toHaveClass(/\bnumber-lines\b/); + } else { + await expect(pre).not.toHaveClass(/\bnumber-lines\b/); + } +}; + +const htmlCases = [ + { name: "html", url: "html/code-annotation-highlight.html" }, + { name: "dashboard", url: "dashboard/code-annotation-highlight.html" }, + { + name: "html without code copy", + url: "html/code-annotation-highlight-no-copy.html", + }, +]; + +test.describe("code annotation line highlight", () => { + for (const { name, url } of htmlCases) { + for (const [label, index] of [["without", 0], ["with", 1]] as const) { + test(`${name} — ${label} line numbers, bands fit the code block`, async ({ page }) => { + await page.goto(getUrl(url), { waitUntil: "load" }); + const block = page.locator("div.sourceCode") + .filter({ has: page.locator("pre.code-annotated") }) + .nth(index); + await expectLineNumbers(block, label === "with"); + await block.scrollIntoViewIfNeeded(); + await expectBandsPlaced(page, block); + }); + } + } + + for ( + const [slide, index, numbered] of [ + ["without-line-numbers", 1, false], + ["with-line-numbers", 2, true], + ] as const + ) { + test(`revealjs — ${slide}, bands fit the code block`, async ({ page }) => { + await page.goto(getUrl("revealjs/code-annotation-highlight.html"), { + waitUntil: "load", + }); + await page.waitForFunction(() => (window as any).Reveal?.isReady()); + await page.evaluate((h) => (window as any).Reveal.slide(h), index); + const section = page.locator(`section#${slide}`); + await expect(section).toHaveClass(/present/); + const block = section.locator("div.sourceCode"); + await expect(block).toBeVisible(); + await expectLineNumbers(block, numbered); + await expectBandsPlaced(page, block); + }); + } + + test("html — inside a scrolled positioned container, bands fit the code block", async ({ page }) => { + // Without the copy button wrapper, the band's containing block is the + // positioned scroll box, so its scroll offset must not shift the band. + await page.goto(getUrl("html/code-annotation-highlight-scrolled.html"), { + waitUntil: "load", + }); + const box = page.locator("#scroll-box"); + const block = box.locator("div.sourceCode"); + await expectLineNumbers(block, false); + await block.scrollIntoViewIfNeeded(); + expect(await box.evaluate((el) => el.scrollTop)).toBeGreaterThan(0); + await expectBandsPlaced(page, block); + }); + + for (const scheme of ["light", "dark"] as const) { + test(`html — ${scheme} theme, highlighted code keeps its contrast`, async ({ page }) => { + // Known gap, older than #13448: the light band tint lowers the + // contrast of the arrow theme's `.im` tokens to 4.24:1. The tokens + // leave no room for a tint, as #14959 shows for Reveal.js. + test.fail(scheme === "light"); + await page.emulateMedia({ colorScheme: scheme }); + await page.goto(getUrl("html/code-annotation-highlight-contrast.html"), { + waitUntil: "load", + }); + await expect(page.locator("body")).toHaveClass( + scheme === "dark" ? /quarto-dark/ : /quarto-light/, + ); + await selectAnnotation( + page, + page.locator("dt[data-target-cell]").first(), + ); + await page.addScriptTag({ + content: readFileSync( + join( + __dirname, + "../../../../src/resources/formats/html/axe/axe.min.js", + ), + "utf8", + ), + }); + const violations = await page.evaluate(async () => { + const axe = (window as any).axe; + const result = await axe.run("div.sourceCode", { + runOnly: { type: "rule", values: ["color-contrast"] }, + }); + return result.violations.flatMap(( + v: { nodes: { target: string[] }[] }, + ) => v.nodes.map((n) => n.target.join(" "))); + }); + expect(violations).toEqual([]); + }); + } +});