Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/@react-spectrum/ai/src/PromptField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
15 changes: 15 additions & 0 deletions packages/@react-spectrum/ai/test/PromptField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
50 changes: 50 additions & 0 deletions packages/react-aria-components/test/TokenField.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(), '<a href="https://example.com">example.com</a>', '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(), '<a href="https://example.com">click here</a>', '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(), '<b>bold</b> text', 'bold text');
await waitForFieldText(getValue, 'bold text');
});
});

describe('undo and redo', () => {
Expand Down
45 changes: 45 additions & 0 deletions packages/react-aria/src/tokenfield/useTokenField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -224,6 +264,11 @@ export function useTokenField<T extends TokenFieldValue = TokenFieldValue>(
: 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');
}
Expand Down