Skip to content

Commit 3770d52

Browse files
jtatarclaude
andcommitted
feat: add linkOnPaste prop to EnrichedTextInput (iOS, Android, Web)
When enabled, pasting clipboard content that is solely a URL over a non-empty selection turns the selected text into a link pointing to that URL instead of replacing the selection. Disabled by default. - Recognizes a URL only when it fully matches linkRegex (or the default link-detection patterns); scheme-less URLs get an https:// prefix. - Falls back to a normal paste for empty/whitespace selections, non-bare-URL clipboard text, or when the link style is blocked (inline code, code block). - No effect when link detection is disabled via linkRegex={null}. JS: prop on the codegen spec, types, default props, native/web wrappers. iOS: gated branch in paste:, tryAddLinkAt:/linkURLIfEntireString: on the view, matchesEntireLinkRegexWithConfig: on LinkStyle. Android: gated branch in handleTextPaste, linkifySelectionOnPaste helper, linkExactRegex companion, setLinkOnPaste ViewManager setter. Web: handleLinkOnPaste wired into TipTap handlePaste. Docs (INPUT_API_REFERENCE), example apps and a Playwright linkOnPaste suite (4 cases, all green; full links suite 35 passed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 42fb60e commit 3770d52

20 files changed

Lines changed: 387 additions & 14 deletions

File tree

.playwright/helpers/clipboard.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,26 @@ export async function pastePlainTextIntoEditor(
5252
);
5353
}, text);
5454
}
55+
56+
/**
57+
* Dispatches a plain-text paste event without first clicking (which would
58+
* collapse the current selection). Use when the paste must land over an
59+
* existing selection, e.g. to exercise `linkOnPaste`.
60+
*/
61+
export async function pastePlainTextOverSelection(
62+
editorInnerLocator: Locator,
63+
text: string
64+
): Promise<void> {
65+
const pm = editorInnerLocator.locator('.ProseMirror');
66+
await pm.evaluate((el, t) => {
67+
const dt = new DataTransfer();
68+
dt.setData('text/plain', t);
69+
el.dispatchEvent(
70+
new ClipboardEvent('paste', {
71+
clipboardData: dt,
72+
bubbles: true,
73+
cancelable: true,
74+
})
75+
);
76+
}, text);
77+
}

.playwright/tests/links.spec.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
copyWholeContent,
1111
pasteIntoWholeContent,
1212
pastePlainTextIntoEditor,
13+
pastePlainTextOverSelection,
1314
} from '../helpers/clipboard';
1415

1516
test.setTimeout(90_000);
@@ -492,6 +493,91 @@ test.describe('test-links copy-paste', () => {
492493
});
493494
});
494495

496+
test.describe('test-links linkOnPaste', () => {
497+
async function selectRange(
498+
page: Page,
499+
start: number,
500+
end: number
501+
): Promise<void> {
502+
await page.fill(sel.selectionStart, String(start));
503+
await page.fill(sel.selectionEnd, String(end));
504+
await page.click(sel.applySelection);
505+
}
506+
507+
test('linkifies the selection when pasting a full URL over it', async ({
508+
page,
509+
}) => {
510+
await gotoTestLinks(page);
511+
await setTestLinksEditorHtml(page, '<html><p>Hello world</p></html>');
512+
await selectRange(page, 6, 11);
513+
514+
await pastePlainTextOverSelection(
515+
page.locator(sel.editorInner),
516+
'https://example.com'
517+
);
518+
519+
await expect
520+
.poll(async () => getTestLinksSerializedHtml(page))
521+
.toContain('<p>Hello <a href="https://example.com">world</a></p>');
522+
});
523+
524+
test('prefixes https:// for a scheme-less URL', async ({ page }) => {
525+
await gotoTestLinks(page);
526+
await setTestLinksEditorHtml(page, '<html><p>Hello world</p></html>');
527+
await selectRange(page, 6, 11);
528+
529+
await pastePlainTextOverSelection(
530+
page.locator(sel.editorInner),
531+
'www.example.com'
532+
);
533+
534+
await expect
535+
.poll(async () => getTestLinksSerializedHtml(page))
536+
.toContain('<p>Hello <a href="https://www.example.com">world</a></p>');
537+
});
538+
539+
test('does not linkify the selection when the pasted text is not a bare URL', async ({
540+
page,
541+
}) => {
542+
await gotoTestLinks(page);
543+
await setTestLinksEditorHtml(page, '<html><p>Hello world</p></html>');
544+
await selectRange(page, 6, 11);
545+
546+
await pastePlainTextOverSelection(
547+
page.locator(sel.editorInner),
548+
'see https://example.com'
549+
);
550+
551+
// The selection is replaced by the pasted text (normal paste), not turned
552+
// into a link — so the selected word "world" must not become a link.
553+
await expect
554+
.poll(async () => getTestLinksSerializedHtml(page))
555+
.toContain('Hello see ');
556+
await expect
557+
.poll(async () => getTestLinksSerializedHtml(page))
558+
.not.toContain('>world</a>');
559+
});
560+
561+
test('does not linkify existing text when there is no selection', async ({
562+
page,
563+
}) => {
564+
await gotoTestLinks(page);
565+
await setTestLinksEditorHtml(page, '<html><p>Hello</p></html>');
566+
await selectRange(page, 5, 5);
567+
568+
await pastePlainTextOverSelection(
569+
page.locator(sel.editorInner),
570+
'https://example.com'
571+
);
572+
573+
// With no selection linkOnPaste is a no-op: the existing "Hello" must not
574+
// be wrapped in a link pointing at the pasted URL.
575+
await expect
576+
.poll(async () => getTestLinksSerializedHtml(page))
577+
.not.toContain('>Hello</a>');
578+
});
579+
});
580+
495581
test.describe('test-links manual link editing', () => {
496582
test('typing inside a manual link keeps the link covering the typed text', async ({
497583
page,

android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,18 @@ class EnrichedTextInputView :
120120
}
121121

122122
var linkRegex: Pattern? = Patterns.WEB_URL
123+
124+
// Unlike linkRegex (wrapped for substring detection), this pattern must
125+
// match the entire string; used to detect a bare-URL paste over a selection.
126+
var linkExactRegex: Pattern? = Patterns.WEB_URL
123127
var spanWatcher: EnrichedSpanWatcher? = null
124128
var layoutManager: EnrichedTextInputViewLayoutManager = EnrichedTextInputViewLayoutManager(this)
125129

126130
var shouldEmitHtml: Boolean = false
127131
var shouldEmitOnChangeText: Boolean = false
128132
var experimentalSynchronousEvents: Boolean = false
129133
var useHtmlNormalizer: Boolean = false
134+
var linkOnPaste: Boolean = false
130135

131136
// Pair: (trigger, style)
132137
var textShortcuts: List<Pair<String, String>> = emptyList()
@@ -376,6 +381,10 @@ class EnrichedTextInputView :
376381
val end = selectionEnd.coerceAtLeast(0)
377382
val lengthBefore = currentText.length
378383

384+
if (linkOnPaste && start < end && linkifySelectionOnPaste(currentText, start, end, item)) {
385+
return
386+
}
387+
379388
val pastedSpannable: Spannable =
380389
when {
381390
item.htmlText != null -> {
@@ -405,6 +414,43 @@ class EnrichedTextInputView :
405414
parametrizedStyles?.afterTextChanged(editable, start.coerceAtMost(pasteEnd), pasteEnd)
406415
}
407416

417+
// Pasting a bare URL over selected text turns the selection into a link
418+
// pointing to that URL instead of replacing it (the linkOnPaste prop).
419+
private fun linkifySelectionOnPaste(
420+
currentText: Spannable,
421+
start: Int,
422+
end: Int,
423+
item: ClipData.Item,
424+
): Boolean {
425+
val regex = linkExactRegex ?: return false
426+
val pasted = item.text?.toString()?.trim() ?: return false
427+
if (pasted.isEmpty() || !regex.matcher(pasted).matches()) return false
428+
429+
if (currentText.substring(start, end).isBlank()) return false
430+
431+
val styles = parametrizedStyles ?: return false
432+
if (!verifyStyle(EnrichedSpans.LINK)) return false
433+
434+
// verifyStyle may remove conflicting styles and shift the selection
435+
val freshStart = selectionStart.coerceAtLeast(0)
436+
val freshEnd = selectionEnd.coerceAtLeast(0)
437+
if (freshStart >= freshEnd) return false
438+
439+
val selectedText = (text as Spannable).substring(freshStart, freshEnd)
440+
if (selectedText.isBlank()) return false
441+
442+
val href =
443+
if (pasted.startsWith("http://", ignoreCase = true) || pasted.startsWith("https://", ignoreCase = true)) {
444+
pasted
445+
} else {
446+
"https://$pasted"
447+
}
448+
449+
styles.setLinkSpan(freshStart, freshEnd, selectedText, href)
450+
setSelection((freshStart + selectedText.length).coerceIn(0, text?.length ?: 0))
451+
return true
452+
}
453+
408454
fun requestFocusProgrammatically() {
409455
requestFocus()
410456
inputMethodManager?.showSoftInput(this, 0)
@@ -636,16 +682,19 @@ class EnrichedTextInputView :
636682
val patternStr = config?.getString("pattern")
637683
if (patternStr == null) {
638684
linkRegex = Patterns.WEB_URL
685+
linkExactRegex = Patterns.WEB_URL
639686
return
640687
}
641688

642689
if (config.getBoolean("isDefault")) {
643690
linkRegex = Patterns.WEB_URL
691+
linkExactRegex = Patterns.WEB_URL
644692
return
645693
}
646694

647695
if (config.getBoolean("isDisabled")) {
648696
linkRegex = null
697+
linkExactRegex = null
649698
return
650699
}
651700

@@ -655,9 +704,11 @@ class EnrichedTextInputView :
655704

656705
try {
657706
linkRegex = Pattern.compile("(?s).*?($patternStr).*", flags)
707+
linkExactRegex = Pattern.compile(patternStr, flags)
658708
} catch (_: PatternSyntaxException) {
659709
Log.w(TAG, "Invalid link regex pattern: $patternStr")
660710
linkRegex = Patterns.WEB_URL
711+
linkExactRegex = Patterns.WEB_URL
661712
}
662713
}
663714

android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,13 @@ class EnrichedTextInputViewManager :
294294
view?.setLinkRegex(config)
295295
}
296296

297+
override fun setLinkOnPaste(
298+
view: EnrichedTextInputView?,
299+
value: Boolean,
300+
) {
301+
view?.linkOnPaste = value
302+
}
303+
297304
override fun setAndroidExperimentalSynchronousEvents(
298305
view: EnrichedTextInputView?,
299306
value: Boolean,

apps/example-web/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ function App() {
282282
mentionIndicators={['@', '#']}
283283
htmlStyle={WEB_DEFAULT_HTML_STYLE}
284284
linkRegex={LINK_REGEX}
285+
linkOnPaste
285286
sanitizationConfig={SANITIZATION_CONFIG}
286287
/>
287288
<MentionPopup

apps/example-web/src/testScreens/TestLinks.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export function TestLinks() {
6969
setLastOnLinkDetected(e);
7070
}}
7171
linkRegex={appliedLinkRegex}
72+
linkOnPaste
7273
/>
7374
</div>
7475

apps/example/src/screens/DevScreen.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export function DevScreen({ onSwitch }: DevScreenProps) {
5252
cursorColor="dodgerblue"
5353
autoCapitalize="sentences"
5454
linkRegex={LINK_REGEX}
55+
linkOnPaste
5556
onChangeText={(e) => editor.handleChangeText(e.nativeEvent)}
5657
onChangeHtml={(e) => editor.handleChangeHtml(e.nativeEvent)}
5758
onChangeState={(e) => editor.handleChangeState(e.nativeEvent)}

apps/example/src/screens/TestScreen.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export function TestScreen({
7575
cursorColor="dodgerblue"
7676
autoCapitalize="sentences"
7777
linkRegex={LINK_REGEX}
78+
linkOnPaste
7879
onChangeText={(e) => editor.handleChangeText(e.nativeEvent)}
7980
onChangeHtml={(e) => editor.handleChangeHtml(e.nativeEvent)}
8081
onChangeState={(e) => editor.handleChangeState(e.nativeEvent)}

docs/INPUT_API_REFERENCE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,16 @@ Keep in mind that not all JS regex features are supported, for example variable-
124124
> [!TIP]
125125
> With this approach you can also disable link detection completely by providing a `null` value as the prop.
126126
127+
### `linkOnPaste`
128+
129+
If `true`, pasting clipboard content that consists solely of a URL while some text is selected turns the selection into a link pointing to that URL, instead of replacing the selected text with the pasted content.
130+
131+
The pasted content is recognized as a URL when it fully matches [`linkRegex`](#linkregex) (or the default link detection patterns when the prop is not provided). URLs without a scheme (e.g. `www.example.com`) get an `https://` prefix in the resulting link. The paste falls back to the regular behavior when the selection is empty or whitespace-only, or when the link style cannot be applied at the selection (e.g. inside inline code or a code block). Has no effect when link detection is disabled with `linkRegex={null}`.
132+
133+
| Type | Default Value | Platform |
134+
| ------ | ------------- | ----------------- |
135+
| `bool` | `false` | iOS, Android, Web |
136+
127137
### `onBlur`
128138

129139
Callback that's called whenever the input loses focus (is blurred).

ios/EnrichedTextInputView.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,19 @@ NS_ASSUME_NONNULL_BEGIN
3535
BOOL blockEmitting;
3636
@public
3737
BOOL useHtmlNormalizer;
38+
@public
39+
BOOL linkOnPaste;
3840
@public
3941
NSValue *dotReplacementRange;
4042
@public
4143
NSArray<NSDictionary *> *textShortcuts;
4244
}
4345
- (CGSize)measureSize:(CGFloat)maxWidth;
46+
- (BOOL)tryAddLinkAt:(NSInteger)start
47+
end:(NSInteger)end
48+
text:(NSString *)text
49+
url:(NSString *)url;
50+
- (nullable NSString *)linkURLIfEntireString:(NSString *)text;
4451
- (void)emitOnLinkDetectedEvent:(LinkData *)linkData range:(NSRange)range;
4552
- (void)emitOnMentionEvent:(NSString *)indicator text:(nullable NSString *)text;
4653
- (void)emitOnPasteImagesEvent:(NSArray<NSDictionary *> *)images;

0 commit comments

Comments
 (0)