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
36 changes: 36 additions & 0 deletions workspace/frontend/components/issues/issue-mention-input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// @vitest-environment jsdom
import { act, createElement, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { expect, it, vi } from 'vitest';
import { IssueMentionInput } from './issue-mention-input';
vi.mock('@/lib/i18n', () => ({ useT: () => (key: string) => key }));
vi.mock('./issue-ui', () => ({ IssueAvatar: () => null }));
it('replaces selected text with Markdown and preserves existing mention identities', async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const host = document.createElement('div');
document.body.append(host);
const root = createRoot(host);
let saved = '';
function Editor() {
const [value, setValue] = useState('@alice@example.com replace');
saved = value;
return createElement(IssueMentionInput, { value, onValueChange: setValue,
mentions: [{ name: 'Alice', token: 'alice@example.com', source: 'human:alice@example.com', kind: 'human' }],
});
}
try {
await act(async () => root.render(createElement(Editor)));
const textarea = host.querySelector('textarea')!;
expect(textarea.value).toBe('@Alice replace');
textarea.setSelectionRange(7, 14);
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: { getData: (type: string) => type === 'text/html' ? '<strong>formatted</strong>' : '' } });
await act(async () => { textarea.dispatchEvent(event); });
expect(event.defaultPrevented).toBe(true);
expect(textarea.value).toBe('@Alice **formatted**');
expect(saved).toBe('@alice@example.com **formatted**');
} finally {
await act(async () => root.unmount());
host.remove();
}
});
17 changes: 17 additions & 0 deletions workspace/frontend/components/issues/issue-mention-input.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';
import { useEffect, useId, useRef, useState, type ComponentProps } from 'react';
import { clipboardMarkdown } from '@/lib/clipboard-markdown';
import { Textarea } from '@/components/ui/textarea';
import { useT } from '@/lib/i18n';
import { cn } from '@/lib/utils';
Expand All @@ -26,6 +27,7 @@ export function IssueMentionInput({
onKeyDown,
onSelect,
onBlur,
onPaste,
...props
}: Props) {
const t = useT();
Expand Down Expand Up @@ -86,6 +88,21 @@ export function IssueMentionInput({
aria-activedescendant={
open && items.length ? `${listId}-${active}` : undefined
}
onPaste={(e) => {
onPaste?.(e);
if (e.defaultPrevented || props.disabled || props.readOnly) return;
const markdown = clipboardMarkdown(e.clipboardData);
if (markdown === null) return;
const { selectionStart: start, selectionEnd: end } = e.currentTarget;
const next = replaceMentionDraft(draft, start, end, markdown);
e.preventDefault();
if (props.maxLength !== undefined && next.length > props.maxLength) return;
onValueChange(next);
setRange(null);
requestAnimationFrame(() => {
input.current?.setSelectionRange(start + markdown.length, start + markdown.length);
});
}}
onChange={(e) => {
onValueChange(editMentionDraft(draft, e.target.value));
detect(e.target);
Expand Down
24 changes: 24 additions & 0 deletions workspace/frontend/lib/clipboard-markdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// @vitest-environment jsdom
import { expect, it } from 'vitest';
import { clipboardMarkdown } from './clipboard-markdown';
const clipboard = (html: string, markdown = '') => ({ getData: (type: string) => type === 'text/html' ? html : type === 'text/markdown' ? markdown : '' });
it('preserves code blocks, bold, inline code and links from formatted text', () => {
const result = clipboardMarkdown(clipboard('<p>Login checker runs:</p><pre><code class="language-arduino">security find-generic-password -s "Claude Code-credentials" -w\n</code></pre><p>The <strong><code>-w</code> retrieves the stored secret itself</strong>. <a href="https://attack.mitre.org/techniques/T1555/001/">MITRE documents this technique</a>.</p>'));
expect(result).toContain('```arduino\nsecurity find-generic-password');
expect(result).toContain('**`-w` retrieves the stored secret itself**');
expect(result).toContain('[MITRE documents this technique](https://attack.mitre.org/techniques/T1555/001/)');
});
it('leaves plain-text Markdown to the native paste handler', () => {
expect(clipboardMarkdown(clipboard(''))).toBeNull();
expect(clipboardMarkdown(clipboard('<div>**bold** and `code`</div>'))).toBeNull();
});
it('prefers explicit Markdown over HTML', () => {
expect(clipboardMarkdown(clipboard('<strong>bold</strong>', '**original**'))).toBe('**original**');
});
it('preserves lists and headings while dropping active content and copy buttons', () => {
const result = clipboardMarkdown(clipboard('<h2>Steps</h2><ul><li>One</li><li><em>Two</em></li></ul><script>alert(1)</script><button>Copy code</button>'));
expect(result).toContain('## Steps');
expect(result).toMatch(/-\s+One/);
expect(result).toContain('_Two_');
expect(result).not.toMatch(/alert|Copy code/);
});
26 changes: 26 additions & 0 deletions workspace/frontend/lib/clipboard-markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import TurndownService from 'turndown';

/** Return Markdown for rich clipboard content, or null for native plain-text paste. */
export function clipboardMarkdown(clipboard: Pick<DataTransfer, 'getData'>): string | null {
const markdown = clipboard.getData('text/markdown');
if (markdown) return markdown;
const html = clipboard.getData('text/html');
if (!html) return null;
try {
// Parse in an inert document; never mount clipboard HTML in the page.
const document = new DOMParser().parseFromString(html, 'text/html');
document.querySelectorAll('script, style, iframe, object, button, input, select, textarea').forEach(node => node.remove());
// Plain text copied from editors is often wrapped in a div or span. Keep it
// verbatim rather than escaping Markdown syntax that the user already wrote.
if (!document.body.querySelector('strong, b, em, i, s, del, a, pre, code, h1, h2, h3, h4, h5, h6, ul, ol, blockquote, hr, img')) return null;
const converter = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', bulletListMarker: '-' });
converter.addRule('strikethrough', {
filter: ['s', 'del'],
replacement: content => `~~${content}~~`,
});
const result = converter.turndown(document.body);
return result.trim() ? result : null;
} catch {
return null; // Clipboard formats vary; ordinary paste must still work.
}
}
28 changes: 28 additions & 0 deletions workspace/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions workspace/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"turndown": "^7.2.4",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2"
},
Expand All @@ -45,6 +46,7 @@
"@types/node": "^24.3.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/turndown": "^5.0.6",
"@vitejs/plugin-react": "^5.2.0",
"jsdom": "^29.1.1",
"postcss": "^8.5.6",
Expand Down
Loading