diff --git a/apps/desktop/e2e/code-scroll.spec.ts b/apps/desktop/e2e/code-scroll.spec.ts new file mode 100644 index 0000000000..388afb486a --- /dev/null +++ b/apps/desktop/e2e/code-scroll.spec.ts @@ -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); +}); diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index 5d7e66cc8b..31d69643ee 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -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 { @@ -26,6 +27,67 @@ it('keeps raw HTML inert instead of expanding the Markdown trust surface', () => assert.doesNotMatch(markup, /
{ + 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); +}); + +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}`)); + } +}); + +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', diff --git a/packages/ui/src/__tests__/message-selection-quote-boundary.test.ts b/packages/ui/src/__tests__/message-selection-quote-boundary.test.ts index b231c6aeff..17d9c08c37 100644 --- a/packages/ui/src/__tests__/message-selection-quote-boundary.test.ts +++ b/packages/ui/src/__tests__/message-selection-quote-boundary.test.ts @@ -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(` +
+
+
long code
+
+

ordinary prose

+
+ `); + 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[] = []; diff --git a/packages/ui/src/markdown-body.tsx b/packages/ui/src/markdown-body.tsx index be0c543499..45a7ccd825 100644 --- a/packages/ui/src/markdown-body.tsx +++ b/packages/ui/src/markdown-body.tsx @@ -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, @@ -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'; /** @@ -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 ( @@ -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 ( -
+
); diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index 858349f129..63bb5dcca7 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -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; } diff --git a/packages/ui/src/use-message-selection-quote.ts b/packages/ui/src/use-message-selection-quote.ts index e7c58527db..14d3dc4418 100644 --- a/packages/ui/src/use-message-selection-quote.ts +++ b/packages/ui/src/use-message-selection-quote.ts @@ -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 { @@ -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,