diff --git a/workspace/frontend/components/issues/issue-mention-input.test.ts b/workspace/frontend/components/issues/issue-mention-input.test.ts new file mode 100644 index 000000000..d24608ae1 --- /dev/null +++ b/workspace/frontend/components/issues/issue-mention-input.test.ts @@ -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' ? 'formatted' : '' } }); + 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(); + } +}); diff --git a/workspace/frontend/components/issues/issue-mention-input.tsx b/workspace/frontend/components/issues/issue-mention-input.tsx index 7a3eb2d95..3c6e78b81 100644 --- a/workspace/frontend/components/issues/issue-mention-input.tsx +++ b/workspace/frontend/components/issues/issue-mention-input.tsx @@ -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'; @@ -26,6 +27,7 @@ export function IssueMentionInput({ onKeyDown, onSelect, onBlur, + onPaste, ...props }: Props) { const t = useT(); @@ -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); diff --git a/workspace/frontend/lib/clipboard-markdown.test.ts b/workspace/frontend/lib/clipboard-markdown.test.ts new file mode 100644 index 000000000..8419aa162 --- /dev/null +++ b/workspace/frontend/lib/clipboard-markdown.test.ts @@ -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('

Login checker runs:

security find-generic-password -s "Claude Code-credentials" -w\n

The -w retrieves the stored secret itself. MITRE documents this technique.

')); + 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('
**bold** and `code`
'))).toBeNull(); +}); +it('prefers explicit Markdown over HTML', () => { + expect(clipboardMarkdown(clipboard('bold', '**original**'))).toBe('**original**'); +}); +it('preserves lists and headings while dropping active content and copy buttons', () => { + const result = clipboardMarkdown(clipboard('

Steps

')); + expect(result).toContain('## Steps'); + expect(result).toMatch(/-\s+One/); + expect(result).toContain('_Two_'); + expect(result).not.toMatch(/alert|Copy code/); +}); diff --git a/workspace/frontend/lib/clipboard-markdown.ts b/workspace/frontend/lib/clipboard-markdown.ts new file mode 100644 index 000000000..7fccbc667 --- /dev/null +++ b/workspace/frontend/lib/clipboard-markdown.ts @@ -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): 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. + } +} diff --git a/workspace/frontend/package-lock.json b/workspace/frontend/package-lock.json index 7b7ca0fc6..4e1c40dcc 100644 --- a/workspace/frontend/package-lock.json +++ b/workspace/frontend/package-lock.json @@ -30,6 +30,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" }, @@ -41,6 +42,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", @@ -2481,6 +2483,12 @@ "@chevrotain/types": "~11.1.1" } }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -5843,6 +5851,13 @@ "license": "MIT", "optional": true }, + "node_modules/@types/turndown": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz", + "integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -10225,6 +10240,19 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/turndown": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", + "integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + }, + "engines": { + "node": ">=18", + "npm": ">=9" + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", diff --git a/workspace/frontend/package.json b/workspace/frontend/package.json index 51c7cc4d4..c36a18d72 100644 --- a/workspace/frontend/package.json +++ b/workspace/frontend/package.json @@ -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" }, @@ -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",