diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 4fee489e00f..325cd71dee2 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -141,7 +141,7 @@ function tokenizeURLs(text: string): TokenFieldSegment[] { if (match.index > start) { segments.push({type: 'text', text: text.slice(start, match.index)}); } - segments.push({type: 'token', text: match[3], value: {type: 'url', url: match[0]}}); + segments.push({type: 'token', text: match[0], value: {type: 'url', url: match[0]}}); start = match.index + match[0].length; } diff --git a/packages/@react-spectrum/ai/test/PromptField.test.tsx b/packages/@react-spectrum/ai/test/PromptField.test.tsx index 61bd1ddc362..5dea956dbef 100644 --- a/packages/@react-spectrum/ai/test/PromptField.test.tsx +++ b/packages/@react-spectrum/ai/test/PromptField.test.tsx @@ -171,6 +171,21 @@ describeOrSkip('PromptField', () => { let urlToken = getValue().segments.find(s => s.type === 'token' && s.value?.type === 'url'); expect(urlToken?.text).toBe('test.com'); }); + + it('keeps the scheme in the token text and outgoing text when typed', async () => { + let {user, textbox, getValue} = renderPromptField(); + await user.click(textbox); + await user.keyboard('visit https://www.test.com now'); + + await waitFor(() => + expect(getValue().segments.some(s => s.type === 'token' && s.value?.type === 'url')).toBe( + true + ) + ); + let urlToken = getValue().segments.find(s => s.type === 'token' && s.value?.type === 'url'); + expect(urlToken?.text).toBe('https://www.test.com'); + expect(getValue().toString()).toContain('https://www.test.com'); + }); }); describe('replacing an existing token', () => { diff --git a/packages/react-aria-components/test/TokenField.browser.test.tsx b/packages/react-aria-components/test/TokenField.browser.test.tsx index cab0f819203..2f0cf1e4987 100644 --- a/packages/react-aria-components/test/TokenField.browser.test.tsx +++ b/packages/react-aria-components/test/TokenField.browser.test.tsx @@ -1055,6 +1055,56 @@ describeOrSkip('TokenField browser interactions', () => { } expect(getValue().toString()).toBe('hi'); }); + + // Real OS clipboard round trips can't carry text/html reliably across engines in CI, and + // useTokenField reads the pasted HTML/plain-text pair off the `beforeinput` event's own + // `dataTransfer` (not off a native clipboard read), so these dispatch that event directly + // with a synthetic DataTransfer to exercise the same code path a real paste would use. + let pasteHtml = (el: Element, html: string, plainText: string) => { + let dt = new DataTransfer(); + dt.setData('text/html', html); + dt.setData('text/plain', plainText); + el.dispatchEvent( + new InputEvent('beforeinput', { + inputType: 'insertFromPaste', + dataTransfer: dt, + bubbles: true, + cancelable: true + }) + ); + }; + + it('recovers a scheme dropped from a pasted rendered link', async () => { + // WebKit doesn't honor a synthetic `dataTransfer` passed to the InputEvent constructor, + // so this can't be exercised without a real (unavailable in CI) OS clipboard round trip. + if (isWebKit()) { + return; + } + let {textbox, getValue} = await renderControlledTokenField(segments(text(''))); + await focusField(textbox); + pasteHtml(textbox.element(), 'example.com', 'example.com'); + await waitForFieldText(getValue, 'https://example.com'); + }); + + it('leaves a pasted link label alone when it is not a scheme-less rendering of its href', async () => { + if (isWebKit()) { + return; + } + let {textbox, getValue} = await renderControlledTokenField(segments(text(''))); + await focusField(textbox); + pasteHtml(textbox.element(), 'click here', 'click here'); + await waitForFieldText(getValue, 'click here'); + }); + + it('pastes non-link HTML unchanged', async () => { + if (isWebKit()) { + return; + } + let {textbox, getValue} = await renderControlledTokenField(segments(text(''))); + await focusField(textbox); + pasteHtml(textbox.element(), 'bold text', 'bold text'); + await waitForFieldText(getValue, 'bold text'); + }); }); describe('undo and redo', () => { diff --git a/packages/react-aria/src/tokenfield/useTokenField.ts b/packages/react-aria/src/tokenfield/useTokenField.ts index 1c0cac2ef1e..be37f63fba3 100644 --- a/packages/react-aria/src/tokenfield/useTokenField.ts +++ b/packages/react-aria/src/tokenfield/useTokenField.ts @@ -90,6 +90,46 @@ export interface TokenFieldAria { const CLIPBOARD_MIME_TYPE = 'application/vnd.react-aria.tokens+json'; +/** + * When pasting a rendered link (e.g. copied from Slack, a doc, etc.), the browser's + * plain-text clipboard representation uses the anchor's visible label, which may omit + * the scheme (e.g. "https://") that's only present in its `href`. This inspects the + * pasted HTML and substitutes an anchor's `href` for its display text when the two + * represent the same URL, so the scheme isn't silently dropped. In every other case + * (no anchors, anchor text doesn't match its href, parsing fails), this returns + * `plainText` unchanged, preserving the existing plain-text paste behavior. + */ +function preferLinkHrefs(html: string, plainText: string): string { + try { + let doc = new DOMParser().parseFromString(html, 'text/html'); + let anchors = doc.body.querySelectorAll('a[href]'); + let result = plainText; + for (let anchor of anchors) { + let displayText = anchor.textContent?.trim(); + let href = anchor.getAttribute('href'); + if (!displayText || !href || !result.includes(displayText)) { + continue; + } + + let hrefWithoutSchemeAndWWW = href + .replace(/^[a-z][a-z0-9+.-]*:\/\//i, '') + .replace(/^www\./i, '') + .replace(/\/$/, ''); + let displayWithoutWWW = displayText.replace(/^www\./i, '').replace(/\/$/, ''); + + // Only substitute when the display text is a scheme/www-less rendering of the + // href itself (i.e. the link's label wasn't custom text unrelated to its target). + if (hrefWithoutSchemeAndWWW === displayWithoutWWW) { + result = result.replace(displayText, href); + } + } + + return result; + } catch { + return plainText; + } +} + /** * Provides the behavior and accessibility implementation for a token field. * A token field allows users to enter text with inline tokens. @@ -224,6 +264,11 @@ export function useTokenField( : null; if (parsed) { data = parsed; + } else if (e.dataTransfer.types.includes('text/html')) { + let html = e.dataTransfer.getData('text/html'); + data[0].text = e.dataTransfer.types.includes('text/plain') + ? preferLinkHrefs(html, e.dataTransfer.getData('text/plain')) + : preferLinkHrefs(html, data[0].text); } else if (e.dataTransfer.types.includes('text/plain')) { data[0].text = e.dataTransfer.getData('text/plain'); }