Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { Markdown } from '.';

vi.mock( 'src/lib/get-ipc-api' );

describe( 'Studio Code Markdown', () => {
const copyText = vi.fn();

beforeEach( () => {
copyText.mockClear();
vi.mocked( getIpcApi, { partial: true } ).mockReturnValue( { copyText } );
} );

it( 'renders a copy button for fenced code blocks and copies the code', async () => {
render( <Markdown>{ '```css\nbody { color: red; }\n```' }</Markdown> );

const button = screen.getByRole( 'button', { name: 'Copy code' } );
expect( button ).toBeInTheDocument();

fireEvent.click( button );

expect( copyText ).toHaveBeenCalledWith( 'body { color: red; }' );
await waitFor( () => expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'Copied' ) );
expect( screen.getByRole( 'button', { name: 'Copy code' } ) ).toBeInTheDocument();
} );

it( 'shows a tooltip for the copy button', async () => {
render( <Markdown>{ '```css\nbody { color: red; }\n```' }</Markdown> );

fireEvent.mouseOver( screen.getByRole( 'button', { name: 'Copy code' } ) );

await waitFor( () => expect( screen.getByRole( 'tooltip' ) ).toHaveTextContent( 'Copy code' ) );
} );

it( 'does not render a copy button for inline code', () => {
render( <Markdown>{ 'this is `inline` code' }</Markdown> );

expect( screen.queryByRole( 'button' ) ).not.toBeInTheDocument();
} );
} );
78 changes: 75 additions & 3 deletions apps/studio/src/components/studio-code-session/markdown/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { useMemo } from 'react';
import { __ } from '@wordpress/i18n';
import { check, copy, Icon } from '@wordpress/icons';
import { isValidElement, useCallback, useEffect, useMemo, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Tooltip } from 'src/components/tooltip';
import { cx } from 'src/lib/cx';
import { getIpcApi } from 'src/lib/get-ipc-api';
import styles from './style.module.css';
import type { MouseEvent } from 'react';
import type { MouseEvent, ReactNode } from 'react';
import type { Components } from 'react-markdown';

// Only hand http(s) links to the OS. Guards against the agent emitting links
Expand All @@ -21,6 +24,75 @@ function isSafeExternalUrl( href: string ): boolean {
}
}

/** Recursively collect the plain-text content of a React node tree. */
function extractText( node: ReactNode ): string {
if ( typeof node === 'string' ) {
return node;
}
if ( typeof node === 'number' ) {
return String( node );
}
if ( Array.isArray( node ) ) {
return node.map( extractText ).join( '' );
}
if ( isValidElement< { children?: ReactNode } >( node ) ) {
return extractText( node.props.children );
}
return '';
}

function CopyButton( { text }: { text: string } ) {
const [ copied, setCopied ] = useState( false );

// Reset back to the idle state a short while after a successful copy.
useEffect( () => {
if ( ! copied ) {
return;
}
const timer = setTimeout( () => setCopied( false ), 2000 );
return () => clearTimeout( timer );
}, [ copied ] );

const handleCopy = useCallback( () => {
void getIpcApi().copyText( text );
setCopied( true );
}, [ text ] );

const copyLabel = __( 'Copy code' );
const copiedLabel = __( 'Copied' );
const tooltipLabel = copied ? copiedLabel : copyLabel;

return (
<div className={ styles.copyButtonContainer }>
<Tooltip text={ tooltipLabel }>
<button
type="button"
className={ styles.copyButton }
onClick={ handleCopy }
aria-label={ copyLabel }
>
<Icon icon={ copied ? check : copy } size={ 16 } fill="currentColor" aria-hidden="true" />
</button>
</Tooltip>
<span className={ styles.visuallyHidden } role="status" aria-live="polite" aria-atomic="true">
{ copied ? copiedLabel : '' }
</span>
</div>
);
}

function CodeBlock( { children }: { children?: ReactNode } ) {
// Strip the single trailing newline react-markdown appends to fenced code.
const text = useMemo( () => extractText( children ).replace( /\n$/, '' ), [ children ] );

return (
<div className={ styles.codeBlock }>
<pre className={ styles.pre }>{ children }</pre>
{ text ? <CopyButton text={ text } /> : null }
</div>
);
}

const baseComponents: Components = {
h1: ( { children } ) => <h1 className={ styles.h1 }>{ children }</h1>,
h2: ( { children } ) => <h2 className={ styles.h2 }>{ children }</h2>,
Expand Down Expand Up @@ -57,7 +129,7 @@ const baseComponents: Components = {
</code>
);
},
pre: ( { children } ) => <pre className={ styles.pre }>{ children }</pre>,
pre: ( { children } ) => <CodeBlock>{ children }</CodeBlock>,
};

export function Markdown( { children, className }: { children: string; className?: string } ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,17 +143,31 @@
color: var(--color-frame-text);
/* Discourage code from breaking awkwardly across lines. */
white-space: nowrap;
/* Code is meant to be copied — keep it selectable. */
-webkit-user-select: text;
user-select: text;
}

.pre {
/* Wrapper anchors the overlaid copy button and carries the block spacing that
used to live on the <pre>. */
.codeBlock {
position: relative;
margin: 0.75em 0 1.25em;
}

.pre {
margin: 0;
padding: 0.875em 1em;
border-radius: var(--wpds-border-radius-md, 8px);
background-color: var(--color-frame-surface);
font-family: var(--wpds-typography-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 0.85em;
line-height: 1.55;
overflow-x: auto;
/* Ensure code stays selectable even when an ancestor opts out of selection
(the app shell sets `select-none` on its drag region). */
-webkit-user-select: text;
user-select: text;
}

.pre > code {
Expand All @@ -165,6 +179,51 @@
white-space: pre;
}

.copyButtonContainer {
position: absolute;
top: 0.5em;
inset-inline-end: 0.5em;
z-index: 1;
}

.copyButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
margin: 0;
padding: 0;
border: 0;
border-radius: var(--wpds-border-radius-sm, 4px);
background-color: var(--color-frame-bg);
background-color: color-mix(in srgb, var(--color-frame-bg) 82%, transparent);
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
color: var(--color-frame-text-secondary);
line-height: 1;
cursor: pointer;
transition: color 0.12s ease, background-color 0.12s ease;
}

.copyButton:hover,
.copyButton:focus-visible {
color: var(--color-frame-text);
background-color: var(--color-frame-surface-alt);
}

.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

.tableWrap {
margin: 0 0 1em;
overflow-x: auto;
Expand Down
56 changes: 56 additions & 0 deletions apps/ui/src/components/markdown/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Tooltip } from '@wordpress/ui';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Markdown } from '.';

const { copyText } = vi.hoisted( () => ( { copyText: vi.fn() } ) );

vi.mock( '@/data/core', () => ( {
useConnector: () => ( {
openExternalUrl: vi.fn(),
copyText,
} ),
} ) );

function renderMarkdown( children: string ) {
return render(
<Tooltip.Provider>
<Markdown>{ children }</Markdown>
</Tooltip.Provider>
);
}

describe( 'Markdown', () => {
beforeEach( () => {
copyText.mockClear();
} );

it( 'renders a copy button for fenced code blocks and copies the code', async () => {
renderMarkdown( '```js\nconst a = 1;\n```' );

const button = screen.getByRole( 'button', { name: 'Copy code' } );
expect( button ).toBeInTheDocument();

fireEvent.click( button );

expect( copyText ).toHaveBeenCalledWith( 'const a = 1;' );
await waitFor( () => expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'Copied' ) );
expect( screen.getByRole( 'button', { name: 'Copy code' } ) ).toBeInTheDocument();
} );

it( 'shows a tooltip for the copy button', async () => {
renderMarkdown( '```js\nconst a = 1;\n```' );

const button = screen.getByRole( 'button', { name: 'Copy code' } );
fireEvent.mouseEnter( button );
fireEvent.mouseMove( button, { movementX: 1, movementY: 1 } );

expect( await screen.findByText( 'Copy code', {}, { timeout: 2000 } ) ).toBeVisible();
} );

it( 'does not render a copy button for inline code', () => {
renderMarkdown( 'this is `inline` code' );

expect( screen.queryByRole( 'button' ) ).not.toBeInTheDocument();
} );
} );
94 changes: 91 additions & 3 deletions apps/ui/src/components/markdown/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,100 @@
import { __ } from '@wordpress/i18n';
import { check, copy, Icon } from '@wordpress/icons';
import { Tooltip } from '@wordpress/ui';
import { clsx } from 'clsx';
import { useMemo } from 'react';
import { isValidElement, useCallback, useEffect, useMemo, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useConnector } from '@/data/core';
import styles from './style.module.css';
import type { MouseEvent } from 'react';
import type { MouseEvent, ReactNode } from 'react';
import type { Components } from 'react-markdown';

/** Recursively collect the plain-text content of a React node tree. */
function extractText( node: ReactNode ): string {
if ( typeof node === 'string' ) {
return node;
}
if ( typeof node === 'number' ) {
return String( node );
}
if ( Array.isArray( node ) ) {
return node.map( extractText ).join( '' );
}
if ( isValidElement< { children?: ReactNode } >( node ) ) {
return extractText( node.props.children );
}
return '';
}

function CopyButton( { text }: { text: string } ) {
const connector = useConnector();
const [ copied, setCopied ] = useState( false );

// Reset back to the idle state a short while after a successful copy.
useEffect( () => {
if ( ! copied ) {
return;
}
const timer = setTimeout( () => setCopied( false ), 2000 );
return () => clearTimeout( timer );
}, [ copied ] );

// Route through the connector (host clipboard) — the renderer's
// `navigator.clipboard` is denied in the Electron desktop, which left the
// copy silently failing and the button stuck on "Copy".
const handleCopy = useCallback( () => {
void connector.copyText( text );
setCopied( true );
}, [ connector, text ] );

const copyLabel = __( 'Copy code' );
const copiedLabel = __( 'Copied' );
const tooltipLabel = copied ? copiedLabel : copyLabel;

return (
<div className={ styles.copyButtonContainer }>
<Tooltip.Root>
<Tooltip.Trigger
render={
<button
type="button"
className={ styles.copyButton }
onClick={ handleCopy }
aria-label={ copyLabel }
>
<Icon
icon={ copied ? check : copy }
size={ 16 }
fill="currentColor"
aria-hidden="true"
/>
</button>
}
/>
<Tooltip.Popup positioner={ <Tooltip.Positioner side="top" /> }>
{ tooltipLabel }
</Tooltip.Popup>
</Tooltip.Root>
<span className={ styles.visuallyHidden } role="status" aria-live="polite" aria-atomic="true">
{ copied ? copiedLabel : '' }
</span>
</div>
);
}

function CodeBlock( { children }: { children?: ReactNode } ) {
// Strip the single trailing newline react-markdown appends to fenced code.
const text = useMemo( () => extractText( children ).replace( /\n$/, '' ), [ children ] );

return (
<div className={ styles.codeBlock }>
<pre className={ styles.pre }>{ children }</pre>
{ text ? <CopyButton text={ text } /> : null }
</div>
);
}

const baseComponents: Components = {
h1: ( { children } ) => <h1 className={ styles.h1 }>{ children }</h1>,
h2: ( { children } ) => <h2 className={ styles.h2 }>{ children }</h2>,
Expand Down Expand Up @@ -43,7 +131,7 @@ const baseComponents: Components = {
</code>
);
},
pre: ( { children } ) => <pre className={ styles.pre }>{ children }</pre>,
pre: ( { children } ) => <CodeBlock>{ children }</CodeBlock>,
};

export function Markdown( { children, className }: { children: string; className?: string } ) {
Expand Down
Loading