diff --git a/.agents/DECISIONS.md b/.agents/DECISIONS.md index 7a01132..ce515df 100644 --- a/.agents/DECISIONS.md +++ b/.agents/DECISIONS.md @@ -71,6 +71,7 @@ The choices that the code does not explain, with the options that were rejected. - **Annotations and footnotes share two note styles, `filled` and `outline`**, set apart for each feature and each block. Annotations default to `filled`, footnotes to `outline`. The block's `data-scb-annotations` or `data-scb-footnotes` holds its style, and sets CSS variables that the rules read, so both styles can share a page. Each style has its own style settings, so a site can recolour one without the other. *Rejected:* one shared option for both features (a site may want them to differ). - **One `startNoteNumber` attribute for annotations and footnotes**, named after EC's `startLineNumber`, since a block uses one note style. *Rejected:* `annotations.start` and `footnotes.start`, whose dotted names read as site options. - **A callout is a sibling of its line**, so line tints and blur do not cover it. It takes the highlight of the lines around it when both sides match. Its width uses `cqi` of the `pre`, except in a side annotation grid, where containment would collapse the code column. +- **A callout's text is selectable only in a selection that starts in its bubble.** A `pointerdown` in a bubble turns on `user-select: text` for that bubble until the next `pointerdown`, so a drag across the code still leaves every bubble out, and what readers see selected is what they copy. *Rejected:* `user-select: contain` (no browser support); a selectable bubble with a `copy` handler that removes callout text (the paste would differ from the selection, as for hidden lines). ### Hidden lines, expandable blocks diff --git a/docs/e2e/callouts.test.ts b/docs/e2e/callouts.test.ts index bf7ed72..9011066 100644 --- a/docs/e2e/callouts.test.ts +++ b/docs/e2e/callouts.test.ts @@ -1,6 +1,6 @@ -import { expect, type Page, test } from '@playwright/test'; +import { expect, type Locator, type Page, test } from '@playwright/test'; import { render } from '../../packages/starlight-codeblocks/test/render.ts'; -import { css, example } from './helpers.ts'; +import { clipboard, css, example } from './helpers.ts'; /** The bubble's box, and the x of the middle of its arrow. */ const arrow = (el: Element) => { @@ -52,6 +52,37 @@ test('shows the note in a bubble above its line, inside the block, with its arro expect(await css(chip, 'borderTopLeftRadius')).toBe('3px'); }); +test('a drag in a bubble selects and copies its text, and a drag across the code leaves the bubble out', async ({ + page, +}) => { + const block = example(page); + const drag = async (from: Locator, to: Locator) => { + const a = await from.boundingBox(); + const b = await to.boundingBox(); + if (!a || !b) throw new Error('No box'); + // The first line of text, and inside the viewport on a phone, where the bubble wraps and the code scrolls. + await page.mouse.move(a.x + 2, a.y + Math.min(8, a.height / 2)); + await page.mouse.down(); + await page.mouse.move(b.x + Math.min(b.width, 250) - 2, b.y + Math.min(8, b.height / 2), { steps: 5 }); + await page.mouse.up(); + await page.keyboard.press('ControlOrMeta+c'); + return page.evaluate(() => getSelection()?.toString() ?? ''); + }; + + const bubble = block.locator('.scb-callout-text'); + expect(await drag(bubble, bubble)).toContain('Lets controller'); + expect(await clipboard(page)).toContain('Lets controller'); + + const lines = block.locator('.ec-line'); + const selected = await drag(lines.first(), lines.nth(1)); + expect(selected).toContain('AbortController'); + expect(selected).toContain('const res'); + expect(selected).not.toContain('cancel'); + const copied = await clipboard(page); + expect(copied).toContain('AbortController'); + expect(copied).not.toContain('cancel'); +}); + test('on a desktop, the arrow points at its text, and a bubble moves left instead of wrapping', async ({ page, isMobile, diff --git a/docs/src/content/docs/features/inline-callouts.mdx b/docs/src/content/docs/features/inline-callouts.mdx index 7a0c5b1..452bf74 100644 --- a/docs/src/content/docs/features/inline-callouts.mdx +++ b/docs/src/content/docs/features/inline-callouts.mdx @@ -80,8 +80,9 @@ const client = createClient({ retries: 3, backoffMs: 250 }); - Screen readers read the bubble before its line. The bubble has the `note` role. - Copy: - The copy button leaves callouts out. A manual selection of the code leaves them out too. + - To select the text of a bubble, start the selection in the bubble. - Without JavaScript: - - Callouts are plain HTML and CSS. They work without JavaScript, and they have no animation. + - Callouts are plain HTML and CSS. They work without JavaScript, and they have no animation. Without JavaScript, the text of a bubble cannot be selected. ## Options diff --git a/packages/starlight-codeblocks/src/client/callouts.ts b/packages/starlight-codeblocks/src/client/callouts.ts new file mode 100644 index 0000000..5c24e24 --- /dev/null +++ b/packages/starlight-codeblocks/src/client/callouts.ts @@ -0,0 +1,18 @@ +const SELECT = 'data-scb-callout-select'; + +let selectable: Element | undefined; +let listening = false; + +/** + * Makes a bubble's text selectable for a selection that starts in it, so that a drag across the code + * still leaves the bubbles out. + */ +export default function initCallouts() { + if (listening) return; + listening = true; + document.addEventListener('pointerdown', (event) => { + selectable?.removeAttribute(SELECT); + selectable = (event.target as Element).closest?.('.scb-callout-bubble') ?? undefined; + selectable?.setAttribute(SELECT, ''); + }); +} diff --git a/packages/starlight-codeblocks/src/client/placeholders.ts b/packages/starlight-codeblocks/src/client/placeholders.ts index b0b0445..232baf3 100644 --- a/packages/starlight-codeblocks/src/client/placeholders.ts +++ b/packages/starlight-codeblocks/src/client/placeholders.ts @@ -123,8 +123,10 @@ function change(text: string, value: string) { function selectedText(pre: HTMLElement, range: Range) { const hidden = (el: Element) => { if (!el.checkVisibility()) return true; + // The nearest element that sets user-select decides, so a selectable callout bubble counts. for (let e: Element | null = el; e && e !== pre; e = e.parentElement) { - if (getComputedStyle(e).userSelect === 'none') return true; + const select = getComputedStyle(e).userSelect; + if (select !== 'auto') return select === 'none'; } return false; }; @@ -157,7 +159,7 @@ function copy(event: ClipboardEvent) { const common = range.commonAncestorContainer; const el = common instanceof Element ? common : common.parentElement; const pre = el?.closest('[data-scb-placeholders]')?.querySelector('pre'); - if (!pre?.contains(common) || !pre.querySelector(FIELD)) return; + if (!pre?.contains(common) || !pre.querySelector(FIELD) || el?.closest('.scb-callout')) return; event.clipboardData.setData('text/plain', selectedText(pre, range)); event.preventDefault(); } diff --git a/packages/starlight-codeblocks/src/expressive-code/callouts.ts b/packages/starlight-codeblocks/src/expressive-code/callouts.ts index 3435737..c7be181 100644 --- a/packages/starlight-codeblocks/src/expressive-code/callouts.ts +++ b/packages/starlight-codeblocks/src/expressive-code/callouts.ts @@ -1,5 +1,6 @@ import { type ExpressiveCodeLine, PluginStyleSettings, type UnresolvedStyleValue } from '@expressive-code/core'; import { type Element, h, select, selectAll, toText } from '@expressive-code/core/hast'; +import { clientJsModules } from '../client-modules.ts'; import { type CodeblocksPlugin, insertBefore, lineElement } from './core.ts'; import { inlineMarkdown } from './inline-markdown.ts'; import { getRenderedDirectives } from './notation.ts'; @@ -73,6 +74,7 @@ export function pluginCallouts(): CodeblocksPlugin { }, }, styleSettings, + jsModules: clientJsModules, baseStyles: ({ cssVar }) => { const x = `(var(--scb-gutter, 0px) + ${cssVar('codePaddingInline')} + var(--scb-callout-mid) * 1ch)`; return ` @@ -97,6 +99,10 @@ pre:has(.${cls()}) { container-type: inline-size; } border: 1px solid ${cssVar('codeblocksCallouts.border')}; border-radius: ${cssVar('codeblocksCallouts.radius')}; } +.${cls('-bubble')}[data-scb-callout-select] { + user-select: text; + -webkit-user-select: text; +} /* On the bubble, so it cannot point past the bubble's end. The bubble keeps the code font size, so 1ch here is the code's. */ .${cls('-bubble')}::after { content: ''; @@ -142,6 +148,7 @@ pre.${PREFIX}-state-all .${cls('-on')} { background: none; }`; const callouts = getRenderedDirectives(context, 'callout'); const code = select('pre > code', context.renderData.blockAst); if (callouts.length === 0 || !code) return; + (select('pre', context.renderData.blockAst) as Element).properties.dataScbCallouts = ''; const lines = selectAll('.ec-line', code); for (const directive of callouts) { const line = directive.lines[0] as ExpressiveCodeLine; diff --git a/packages/starlight-codeblocks/test/callouts.test.ts b/packages/starlight-codeblocks/test/callouts.test.ts index 1e400f7..10d6c80 100644 --- a/packages/starlight-codeblocks/test/callouts.test.ts +++ b/packages/starlight-codeblocks/test/callouts.test.ts @@ -13,6 +13,8 @@ test('renders a callout above its line, pointing at the middle of the match', as expect(html).not.toContain('[!callout'); expect(copyText).toBe('const a = 1;\nfetch(url, { signal });'); expect(warnings).toEqual([]); + expect(html).toContain('data-scb-callouts=""'); + expect((await render(block('js', 'fetch(url);'))).html).not.toContain('data-scb-callouts'); }); test('calloutMiddle points at the first character that is not whitespace, counting tabs and the shell prompt', async () => {