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
106 changes: 106 additions & 0 deletions apps/desktop/e2e/code-scroll.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { expect, test, COMPOSER_INPUT } from './fixtures';

test('a one-line Markdown code block exposes native and selection horizontal scrolling', async ({
window: page,
}) => {
await page.setViewportSize({ width: 900, height: 700 });
const longLine = Array.from(
{ length: 80 },
(_, index) => `word-${String(index).padStart(3, '0')}`,
).join(' ');
const composer = page.locator(COMPOSER_INPUT);
await composer.fill([
'show these keys',
'',
'```',
'short-key',
'```',
'',
'```',
longLine,
'```',
].join('\n'));
await composer.press('Enter');

const codeBlocks = page.locator('.maka-markdown-code[data-maka-code-layout="single-line"]');
const viewport = codeBlocks.last().locator('[role="group"]');
await expect(viewport).toBeVisible();
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, {
timeout: 20_000,
});

const metrics = await viewport.evaluate((element) => {
const node = element as HTMLElement;
const rect = node.getBoundingClientRect();
const code = node.querySelector('code');
const line = code?.querySelector(':scope > [data-line]');
if (!code || !line) throw new Error('code viewport has no line content');
const codeRect = code.getBoundingClientRect();
const lineRect = line.getBoundingClientRect();
return {
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
clientWidth: node.clientWidth,
scrollWidth: node.scrollWidth,
lineTopInset: lineRect.top - codeRect.top,
lineBottomInset: codeRect.bottom - lineRect.bottom,
};
});
expect(metrics.scrollWidth).toBeGreaterThan(metrics.clientWidth);
expect(Math.abs(metrics.lineTopInset - metrics.lineBottomInset)).toBeLessThanOrEqual(1);

const overflowX = await viewport.evaluate((element) => getComputedStyle(element).overflowX);
expect(overflowX).toBe('auto');

const viewportBox = await viewport.boundingBox();
if (!viewportBox) throw new Error('native scroll viewport has no visible bounds');
await page.mouse.move(
viewportBox.x + viewportBox.width / 2,
viewportBox.y + viewportBox.height / 2,
);
await page.mouse.wheel(240, 0);
await expect.poll(
() => viewport.evaluate((element) => (element as HTMLElement).scrollLeft),
).toBeGreaterThan(0);
const afterWheelScroll = await viewport.evaluate(
(element) => (element as HTMLElement).scrollLeft,
);

await viewport.evaluate((element) => {
(element as HTMLElement).scrollLeft = 0;
});
await viewport.focus();
await viewport.press('ArrowRight');
await expect.poll(
() => viewport.evaluate((element) => (element as HTMLElement).scrollLeft),
).toBeGreaterThan(0);
const afterKeyboardScroll = await viewport.evaluate(
(element) => (element as HTMLElement).scrollLeft,
);

await viewport.evaluate((element) => {
(element as HTMLElement).scrollLeft = 0;
window.getSelection()?.removeAllRanges();
});
const code = viewport.locator('code');
const codeBox = await code.boundingBox();
if (!codeBox) throw new Error('code line has no visible bounds');
const textY = codeBox.y + Math.min(codeBox.height / 2, 18);
await page.mouse.move(codeBox.x + 24, textY);
await page.mouse.down();
await page.mouse.move(metrics.rect.x + metrics.rect.width + 50, textY, { steps: 20 });
await expect.poll(
() => viewport.evaluate((element) => (element as HTMLElement).scrollLeft),
).toBeGreaterThan(0);
await expect.poll(
() => viewport.evaluate(() => window.getSelection()?.toString().length ?? 0),
).toBeGreaterThan(10);
const afterSelectionDrag = await viewport.evaluate((element) => ({
scrollLeft: (element as HTMLElement).scrollLeft,
selection: window.getSelection()?.toString() ?? '',
}));
await page.mouse.up();
expect(afterWheelScroll).toBeGreaterThan(0);
expect(afterKeyboardScroll).toBeGreaterThan(0);
expect(afterSelectionDrag.scrollLeft).toBeGreaterThan(0);
expect(afterSelectionDrag.selection.length).toBeGreaterThan(10);
});
62 changes: 62 additions & 0 deletions packages/ui/src/__tests__/markdown-body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
MAX_AUTOMATIC_MERMAID_SOURCE_LENGTH,
MAX_AUTOMATIC_MERMAID_TOTAL_SOURCE_LENGTH,
} from '../markdown-body.js';
import { AstryxLocaleProvider } from '../astryx-i18n.js';
import { MakaUriContext, Markdown } from '../markdown.js';
import { LocaleProvider } from '../locale-context.js';
import {
Expand All @@ -26,6 +27,67 @@ it('keeps raw HTML inert instead of expanding the Markdown trust surface', () =>
assert.doesNotMatch(markup, /<details/);
});

it('keeps the copy control in a toolbar above a one-line code scroll viewport', () => {
const markup = renderToStaticMarkup(createElement(LocaleProvider, {
locale: 'en',
children: createElement(MarkdownBody, {
text: ['```', `ssh-ed25519 ${'A'.repeat(200)}`, '```'].join('\n'),
}),
}));

const toolbarIndex = markup.indexOf('astryx-codeblock-header');
const copyButtonIndex = markup.indexOf('astryx-codeblock-copy-button');
const scrollViewportIndex = markup.indexOf('role="group"');

assert.match(markup, /data-maka-code-layout="single-line"/);
assert.ok(toolbarIndex >= 0);
assert.ok(copyButtonIndex > toolbarIndex);
assert.ok(scrollViewportIndex > copyButtonIndex);
Comment thread
Astro-Han marked this conversation as resolved.
});

it('does not force the single-line scrollbar layout on multiline code', () => {
const markup = renderToStaticMarkup(createElement(LocaleProvider, {
locale: 'en',
children: createElement(MarkdownBody, {
text: ['```ts', 'const first = 1;', 'const second = 2;', '```'].join('\n'),
}),
}));

assert.match(markup, /data-maka-code-layout="multi-line"/);
assert.match(markup, /astryx-codeblock-header/);
assert.match(markup, /astryx-codeblock-copy-button/);
});

it('gives collapsible plaintext code a localized accessible name', () => {
const code = Array.from({ length: 10 }, (_, index) => `line ${index + 1}`);

for (const [locale, label] of [['en', 'Code'], ['zh', '代码']] as const) {
const markup = renderToStaticMarkup(createElement(LocaleProvider, {
locale,
children: createElement(AstryxLocaleProvider, {
children: createElement(MarkdownBody, {
text: ['```', ...code, '```'].join('\n'),
}),
}),
}));

assert.match(markup, /role="button"/);
assert.match(markup, /aria-expanded="true"/);
assert.match(markup, new RegExp(`>${label}</span>`));
}
});

it('keeps standalone MarkdownBody compatible for collapsible plaintext code', () => {
const code = Array.from({ length: 10 }, (_, index) => `line ${index + 1}`);
const markup = renderToStaticMarkup(createElement(MarkdownBody, {
text: ['```', ...code, '```'].join('\n'),
}));

assert.match(markup, /role="button"/);
assert.match(markup, /aria-expanded="true"/);
assert.match(markup, />Code<\/span>/);
});

it('keeps a lazy live stream behind the display cursor', () => {
const markup = renderToStaticMarkup(createElement(Markdown, {
text: 'live output that has not reached the display cursor',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createSelectionQuoteGestureBoundary } from '../use-message-selection-quote.js';
import { parseHTML } from 'linkedom';
import {
createSelectionQuoteGestureBoundary,
preservesNativeSelectionScroll,
} from '../use-message-selection-quote.js';

test('Markdown code keeps its native selection and scrollbar pointer gesture', () => {
const { document } = parseHTML(`
<article data-turn-id="turn-1">
<div class="maka-markdown-code">
<div role="group"><code><span id="code-text">long code</span></code></div>
</div>
<p id="prose">ordinary prose</p>
</article>
`);
const turn = document.querySelector('[data-turn-id]');
const codeText = document.querySelector('#code-text');
const prose = document.querySelector('#prose');

assert.ok(turn);
assert.ok(codeText);
assert.ok(prose);
assert.equal(preservesNativeSelectionScroll(codeText, turn), true);
assert.equal(preservesNativeSelectionScroll(prose, turn), false);
});

test('drag selection settles only after its owning pointer is released', () => {
const effects: string[] = [];
Expand Down
19 changes: 18 additions & 1 deletion packages/ui/src/markdown-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from '@astryxdesign/core/Markdown';
import { Link as AstryxLink } from '@astryxdesign/core/Link';
import { CodeBlock } from '@astryxdesign/core/CodeBlock';
import { useTranslator } from '@astryxdesign/core/i18n';
import {
isMakaUriCandidate,
isSafeExternalScheme,
Expand All @@ -35,6 +36,7 @@ const BASE_MARKDOWN_COMPONENTS = {
export const MAX_AUTOMATIC_MERMAID_DIAGRAMS = 3;
export const MAX_AUTOMATIC_MERMAID_SOURCE_LENGTH = 4_000;
export const MAX_AUTOMATIC_MERMAID_TOTAL_SOURCE_LENGTH = 8_000;
const CODE_BLOCK_COLLAPSIBLE_THRESHOLD = 10;
const DEFERRED_MERMAID_LANGUAGE = 'makamermaiddeferred';

/**
Expand Down Expand Up @@ -175,6 +177,7 @@ function MarkdownCode(props: {
density: 'default' | 'compact';
renderMermaid: boolean;
}) {
const t = useTranslator();
const language = props.language?.trim().toLowerCase();
if (props.renderMermaid && (language === 'mermaid' || language === DEFERRED_MERMAID_LANGUAGE)) {
return (
Expand All @@ -186,12 +189,26 @@ function MarkdownCode(props: {
);
}

const codeLines = props.code.split('\n');
if (codeLines.length > 1 && codeLines.at(-1) === '') codeLines.pop();
const isSingleLine = codeLines.length === 1;
const isCollapsible = codeLines.length >= CODE_BLOCK_COLLAPSIBLE_THRESHOLD;
const hasLanguageLabel = Boolean(language && language !== 'plaintext');

return (
<div className={`maka-markdown-code maka-markdown-code-${props.density}`}>
<div
className={`maka-markdown-code maka-markdown-code-${props.density}`}
data-maka-code-layout={isSingleLine ? 'single-line' : 'multi-line'}>
<CodeBlock
code={props.code}
language={props.language}
// Astryx otherwise overlays the copy button on headerless plaintext.
// An empty title enables its structural header; once that header becomes
// a collapse button, give plaintext the same localized code label that
// language fences already provide through their language label.
title={isCollapsible && !hasLanguageLabel ? t('@astryx.codeBlock.code') : ''}
isCollapsible
collapsibleThreshold={CODE_BLOCK_COLLAPSIBLE_THRESHOLD}
/>
</div>
);
Expand Down
38 changes: 38 additions & 0 deletions packages/ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,44 @@
flow — the compact rhythm above already carries it, and only the document
mode needs its margins declared here. */
.maka-markdown-code { min-width: 0; }
/* Markdown always gives CodeBlock a structural header, keeping its copy button
in a real toolbar instead of overlaying the horizontal scroll viewport. */
.maka-markdown-code .astryx-codeblock-header {
min-height: 32px;
padding-block: 2px;
padding-inline: var(--spacing-2);
border-block-end: 1px solid var(--border);
}
/* Astryx pulls a headered code body upward by spacing-2. That is useful when
the header is only a language caption, but our header is a distinct toolbar:
the pull clips the top padding to 4px while leaving 12px below the line. */
.maka-markdown-code .astryx-codeblock [role="group"] > div {
margin-block-start: 0;
}
/* A one-line command, key, or token keeps Astryx's native scrolling viewport,
selection, keyboard behavior, and platform scrollbar. */
.maka-markdown-code[data-maka-code-layout="single-line"] .astryx-codeblock [role="group"] {
overflow-x: auto;
overflow-y: hidden;
}
.maka-markdown-code[data-maka-code-layout="single-line"] .astryx-codeblock [role="group"] > div > code {
box-sizing: border-box;
display: flex;
align-items: center;
min-block-size: 40px;
padding-block: 0;
}
/* The CodeBlock scroll viewport is focusable but Astryx leaves it on the UA
outline. Give it one clipped-safe Maka ring, then stop the non-interactive
prose ListItem's generic focus-within ring from drawing a second outline
around the entire numbered-list row. */
.maka-markdown-code .astryx-codeblock [role="group"]:focus-visible {
outline: none;
box-shadow: inset 0 0 0 var(--focus-ring-width) var(--focus-ring);
}
[data-maka-contract="markdown"] .astryx-list-item:has(.maka-markdown-code :focus-visible) {
outline: none;
}
.maka-markdown-code-default { margin-block: var(--space-3) var(--space-4); }
[role="document"] > .maka-markdown-code:first-child { margin-block-start: 0; }
[role="document"] > .maka-markdown-code:last-child { margin-block-end: 0; }
Expand Down
16 changes: 16 additions & 0 deletions packages/ui/src/use-message-selection-quote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ const INTERACTIVE_QUOTE_TARGET = [
'[role="tab"]',
].join(',');

// A Markdown CodeBlock owns a native horizontal-scroll gesture. Capturing a
// pointer that starts here onto the enclosing Turn prevents Chromium from
// dragging the scrollbar thumb and from auto-scrolling while text selection
// crosses the viewport edge. Selection changes still reach this hook and can
// produce a quote; only the Turn-level pointer capture is skipped.
const NATIVE_SELECTION_SCROLL_TARGET = '.maka-markdown-code [role="group"]';

export function preservesNativeSelectionScroll(
target: Element | null,
turnOwner: Element,
): boolean {
const scrollOwner = target?.closest(NATIVE_SELECTION_SCROLL_TARGET) ?? null;
return scrollOwner !== null && turnOwner.contains(scrollOwner);
}

export type SelectionQuoteGestureDisposition = 'commit' | 'cancel';

export interface SelectionQuoteGestureBoundary<Owner> {
Expand Down Expand Up @@ -216,6 +231,7 @@ export function useMessageSelectionQuote(
!turnOwner ||
!root.contains(turnOwner) ||
(interactiveOwner !== null && turnOwner.contains(interactiveOwner)) ||
preservesNativeSelectionScroll(targetElement, turnOwner) ||
findEnclosingTurnId(
targetNode as unknown as QuoteScopeNode,
root as unknown as QuoteScopeNode,
Expand Down
Loading