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 .agents/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
35 changes: 33 additions & 2 deletions docs/e2e/callouts.test.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion docs/src/content/docs/features/inline-callouts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions packages/starlight-codeblocks/src/client/callouts.ts
Original file line number Diff line number Diff line change
@@ -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, '');
});
}
6 changes: 4 additions & 2 deletions packages/starlight-codeblocks/src/client/placeholders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -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<HTMLElement>('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();
}
Expand Down
7 changes: 7 additions & 0 deletions packages/starlight-codeblocks/src/expressive-code/callouts.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 `
Expand All @@ -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: '';
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/starlight-codeblocks/test/callouts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading