From c2cab861bc65d419fd862bbb4fe50c9104346f41 Mon Sep 17 00:00:00 2001 From: Guido X Jansen Date: Tue, 10 Mar 2026 16:46:36 +0100 Subject: [PATCH] feat(editor): smart link paste wraps selected text as markdown link (#217) When text is selected in the MarkdownEditor and a URL (http/https) is pasted, the selected text is preserved and wrapped as [text](url) instead of being replaced. Normal paste behavior is unchanged when no text is selected or when pasted content is not a URL. Closes singi-labs/barazo-workspace#124 --- src/components/markdown-editor.test.tsx | 83 +++++++++++++++++++++++++ src/components/markdown-editor.tsx | 29 +++++++++ 2 files changed, 112 insertions(+) diff --git a/src/components/markdown-editor.test.tsx b/src/components/markdown-editor.test.tsx index 3d8d1cb..6a1c8fc 100644 --- a/src/components/markdown-editor.test.tsx +++ b/src/components/markdown-editor.test.tsx @@ -132,6 +132,89 @@ describe('MarkdownEditor', () => { expect(screen.getByRole('textbox', { name: 'Content' })).toHaveAttribute('aria-invalid', 'true') }) + describe('smart link paste', () => { + function pasteUrl(textarea: HTMLTextAreaElement, url: string) { + const event = new Event('paste', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'clipboardData', { + value: { getData: (type: string) => (type === 'text/plain' ? url : '') }, + }) + textarea.dispatchEvent(event) + return event + } + + it('wraps selected text as markdown link when pasting a URL', () => { + const onChange = vi.fn() + render( + + ) + const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement + + // Select "this" + textarea.setSelectionRange(6, 10) + pasteUrl(textarea, 'https://example.com') + + expect(onChange).toHaveBeenCalledWith('check [this](https://example.com) out') + }) + + it('does not intercept paste when no text is selected', () => { + const onChange = vi.fn() + render() + const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement + + textarea.setSelectionRange(6, 6) + const event = pasteUrl(textarea, 'https://example.com') + + // Event should not be prevented — default paste behavior + expect(event.defaultPrevented).toBe(false) + expect(onChange).not.toHaveBeenCalled() + }) + + it('does not intercept paste when pasted text is not a URL', () => { + const onChange = vi.fn() + render( + + ) + const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement + + textarea.setSelectionRange(6, 11) + const event = pasteUrl(textarea, 'not a url') + + expect(event.defaultPrevented).toBe(false) + expect(onChange).not.toHaveBeenCalled() + }) + + it('handles http:// URLs', () => { + const onChange = vi.fn() + render( + + ) + const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement + + textarea.setSelectionRange(6, 10) + pasteUrl(textarea, 'http://example.com') + + expect(onChange).toHaveBeenCalledWith('click [here](http://example.com) please') + }) + + it('places cursor after the inserted link', () => { + const onChange = vi.fn() + render( + + ) + const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement + + textarea.setSelectionRange(4, 8) + pasteUrl(textarea, 'https://docs.example.com') + + expect(onChange).toHaveBeenCalledWith('see [docs](https://docs.example.com) now') + }) + }) + it('passes axe accessibility check', async () => { const { container } = render( diff --git a/src/components/markdown-editor.tsx b/src/components/markdown-editor.tsx index 8bfd0e4..359672f 100644 --- a/src/components/markdown-editor.tsx +++ b/src/components/markdown-editor.tsx @@ -59,6 +59,34 @@ export function MarkdownEditor({ [value, onChange] ) + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + const textarea = textareaRef.current + if (!textarea) return + + const start = textarea.selectionStart + const end = textarea.selectionEnd + if (start === end) return // No selection — let default paste happen + + const pasted = e.clipboardData.getData('text/plain') + if (!/^https?:\/\/\S+$/.test(pasted)) return // Not a URL + + e.preventDefault() + const before = value.slice(0, start) + const selected = value.slice(start, end) + const after = value.slice(end) + const link = `[${selected}](${pasted})` + onChange(before + link + after) + + const cursorPos = start + link.length + requestAnimationFrame(() => { + textarea.focus() + textarea.setSelectionRange(cursorPos, cursorPos) + }) + }, + [value, onChange] + ) + const handleToolbarKeyDown = useCallback( (e: React.KeyboardEvent) => { const buttons = toolbarRef.current?.querySelectorAll('button') @@ -127,6 +155,7 @@ export function MarkdownEditor({ id={id} value={value} onChange={(e) => onChange(e.target.value)} + onPaste={handlePaste} placeholder={placeholder ?? 'Write your content using Markdown...'} required={required} aria-invalid={error ? 'true' : undefined} -- 2.51.2