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
23 changes: 23 additions & 0 deletions .playwright/helpers/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,26 @@ export async function pastePlainTextIntoEditor(
);
}, text);
}

/**
* Dispatches a plain-text paste event without first clicking (which would
* collapse the current selection). Use when the paste must land over an
* existing selection, e.g. to exercise `linkOnPaste`.
*/
export async function pastePlainTextOverSelection(
editorInnerLocator: Locator,
text: string
): Promise<void> {
const pm = editorInnerLocator.locator('.ProseMirror');
await pm.evaluate((el, t) => {
const dt = new DataTransfer();
dt.setData('text/plain', t);
el.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dt,
bubbles: true,
cancelable: true,
})
);
}, text);
}
86 changes: 86 additions & 0 deletions .playwright/tests/links.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
copyWholeContent,
pasteIntoWholeContent,
pastePlainTextIntoEditor,
pastePlainTextOverSelection,
} from '../helpers/clipboard';

test.setTimeout(90_000);
Expand Down Expand Up @@ -492,6 +493,91 @@ test.describe('test-links copy-paste', () => {
});
});

test.describe('test-links linkOnPaste', () => {
async function selectRange(
page: Page,
start: number,
end: number
): Promise<void> {
await page.fill(sel.selectionStart, String(start));
await page.fill(sel.selectionEnd, String(end));
await page.click(sel.applySelection);
}

test('linkifies the selection when pasting a full URL over it', async ({
page,
}) => {
await gotoTestLinks(page);
await setTestLinksEditorHtml(page, '<html><p>Hello world</p></html>');
await selectRange(page, 6, 11);

await pastePlainTextOverSelection(
page.locator(sel.editorInner),
'https://example.com'
);

await expect
.poll(async () => getTestLinksSerializedHtml(page))
.toContain('<p>Hello <a href="https://example.com">world</a></p>');
});

test('prefixes https:// for a scheme-less URL', async ({ page }) => {
await gotoTestLinks(page);
await setTestLinksEditorHtml(page, '<html><p>Hello world</p></html>');
await selectRange(page, 6, 11);

await pastePlainTextOverSelection(
page.locator(sel.editorInner),
'www.example.com'
);

await expect
.poll(async () => getTestLinksSerializedHtml(page))
.toContain('<p>Hello <a href="https://www.example.com">world</a></p>');
});

test('does not linkify the selection when the pasted text is not a bare URL', async ({
page,
}) => {
await gotoTestLinks(page);
await setTestLinksEditorHtml(page, '<html><p>Hello world</p></html>');
await selectRange(page, 6, 11);

await pastePlainTextOverSelection(
page.locator(sel.editorInner),
'see https://example.com'
);

// The selection is replaced by the pasted text (normal paste), not turned
// into a link — so the selected word "world" must not become a link.
await expect
.poll(async () => getTestLinksSerializedHtml(page))
.toContain('Hello see ');
await expect
.poll(async () => getTestLinksSerializedHtml(page))
.not.toContain('>world</a>');
});

test('does not linkify existing text when there is no selection', async ({
page,
}) => {
await gotoTestLinks(page);
await setTestLinksEditorHtml(page, '<html><p>Hello</p></html>');
await selectRange(page, 5, 5);

await pastePlainTextOverSelection(
page.locator(sel.editorInner),
'https://example.com'
);

// With no selection linkOnPaste is a no-op: the existing "Hello" must not
// be wrapped in a link pointing at the pasted URL.
await expect
.poll(async () => getTestLinksSerializedHtml(page))
.not.toContain('>Hello</a>');
});
});

test.describe('test-links manual link editing', () => {
test('typing inside a manual link keeps the link covering the typed text', async ({
page,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,18 @@ class EnrichedTextInputView :
}

var linkRegex: Pattern? = Patterns.WEB_URL

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

var shouldEmitHtml: Boolean = false
var shouldEmitOnChangeText: Boolean = false
var experimentalSynchronousEvents: Boolean = false
var useHtmlNormalizer: Boolean = false
var linkOnPaste: Boolean = false

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

if (linkOnPaste && start < end && linkifySelectionOnPaste(currentText, start, end, item)) {
return
}

val pastedSpannable: Spannable =
when {
item.htmlText != null -> {
Expand Down Expand Up @@ -405,6 +414,43 @@ class EnrichedTextInputView :
parametrizedStyles?.afterTextChanged(editable, start.coerceAtMost(pasteEnd), pasteEnd)
}

// Pasting a bare URL over selected text turns the selection into a link
// pointing to that URL instead of replacing it (the linkOnPaste prop).
private fun linkifySelectionOnPaste(
currentText: Spannable,
start: Int,
end: Int,
item: ClipData.Item,
): Boolean {
val regex = linkExactRegex ?: return false
val pasted = item.text?.toString()?.trim() ?: return false
if (pasted.isEmpty() || !regex.matcher(pasted).matches()) return false

if (currentText.substring(start, end).isBlank()) return false

val styles = parametrizedStyles ?: return false
if (!verifyStyle(EnrichedSpans.LINK)) return false

// verifyStyle may remove conflicting styles and shift the selection
val freshStart = selectionStart.coerceAtLeast(0)
val freshEnd = selectionEnd.coerceAtLeast(0)
if (freshStart >= freshEnd) return false

val selectedText = (text as Spannable).substring(freshStart, freshEnd)
if (selectedText.isBlank()) return false

val href =
if (pasted.startsWith("http://", ignoreCase = true) || pasted.startsWith("https://", ignoreCase = true)) {
pasted
} else {
"https://$pasted"
}

styles.setLinkSpan(freshStart, freshEnd, selectedText, href)
setSelection((freshStart + selectedText.length).coerceIn(0, text?.length ?: 0))
return true
}

fun requestFocusProgrammatically() {
requestFocus()
inputMethodManager?.showSoftInput(this, 0)
Expand Down Expand Up @@ -636,16 +682,19 @@ class EnrichedTextInputView :
val patternStr = config?.getString("pattern")
if (patternStr == null) {
linkRegex = Patterns.WEB_URL
linkExactRegex = Patterns.WEB_URL
return
}

if (config.getBoolean("isDefault")) {
linkRegex = Patterns.WEB_URL
linkExactRegex = Patterns.WEB_URL
return
}

if (config.getBoolean("isDisabled")) {
linkRegex = null
linkExactRegex = null
return
}

Expand All @@ -655,9 +704,11 @@ class EnrichedTextInputView :

try {
linkRegex = Pattern.compile("(?s).*?($patternStr).*", flags)
linkExactRegex = Pattern.compile(patternStr, flags)
} catch (_: PatternSyntaxException) {
Log.w(TAG, "Invalid link regex pattern: $patternStr")
linkRegex = Patterns.WEB_URL
linkExactRegex = Patterns.WEB_URL
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,13 @@ class EnrichedTextInputViewManager :
view?.setLinkRegex(config)
}

override fun setLinkOnPaste(
view: EnrichedTextInputView?,
value: Boolean,
) {
view?.linkOnPaste = value
}

override fun setAndroidExperimentalSynchronousEvents(
view: EnrichedTextInputView?,
value: Boolean,
Expand Down
1 change: 1 addition & 0 deletions apps/example-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ function App() {
mentionIndicators={['@', '#']}
htmlStyle={WEB_DEFAULT_HTML_STYLE}
linkRegex={LINK_REGEX}
linkOnPaste
sanitizationConfig={SANITIZATION_CONFIG}
/>
<MentionPopup
Expand Down
1 change: 1 addition & 0 deletions apps/example-web/src/testScreens/TestLinks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export function TestLinks() {
setLastOnLinkDetected(e);
}}
linkRegex={appliedLinkRegex}
linkOnPaste
/>
</div>

Expand Down
1 change: 1 addition & 0 deletions apps/example/src/screens/DevScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export function DevScreen({ onSwitch }: DevScreenProps) {
cursorColor="dodgerblue"
autoCapitalize="sentences"
linkRegex={LINK_REGEX}
linkOnPaste
onChangeText={(e) => editor.handleChangeText(e.nativeEvent)}
onChangeHtml={(e) => editor.handleChangeHtml(e.nativeEvent)}
onChangeState={(e) => editor.handleChangeState(e.nativeEvent)}
Expand Down
1 change: 1 addition & 0 deletions apps/example/src/screens/TestScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export function TestScreen({
cursorColor="dodgerblue"
autoCapitalize="sentences"
linkRegex={LINK_REGEX}
linkOnPaste
onChangeText={(e) => editor.handleChangeText(e.nativeEvent)}
onChangeHtml={(e) => editor.handleChangeHtml(e.nativeEvent)}
onChangeState={(e) => editor.handleChangeState(e.nativeEvent)}
Expand Down
10 changes: 10 additions & 0 deletions docs/INPUT_API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@ Keep in mind that not all JS regex features are supported, for example variable-
> [!TIP]
> With this approach you can also disable link detection completely by providing a `null` value as the prop.

### `linkOnPaste`

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.

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}`.

| Type | Default Value | Platform |
| ------ | ------------- | ----------------- |
| `bool` | `false` | iOS, Android, Web |

### `onBlur`

Callback that's called whenever the input loses focus (is blurred).
Expand Down
7 changes: 7 additions & 0 deletions ios/EnrichedTextInputView.h
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,19 @@ NS_ASSUME_NONNULL_BEGIN
BOOL blockEmitting;
@public
BOOL useHtmlNormalizer;
@public
BOOL linkOnPaste;
@public
NSValue *dotReplacementRange;
@public
NSArray<NSDictionary *> *textShortcuts;
}
- (CGSize)measureSize:(CGFloat)maxWidth;
- (BOOL)tryAddLinkAt:(NSInteger)start
end:(NSInteger)end
text:(NSString *)text
url:(NSString *)url;
- (nullable NSString *)linkURLIfEntireString:(NSString *)text;
- (void)emitOnLinkDetectedEvent:(LinkData *)linkData range:(NSRange)range;
- (void)emitOnMentionEvent:(NSString *)indicator text:(nullable NSString *)text;
- (void)emitOnPasteImagesEvent:(NSArray<NSDictionary *> *)images;
Expand Down
57 changes: 47 additions & 10 deletions ios/EnrichedTextInputView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,11 @@ - (void)updateProps:(Props::Shared const &)props
useHtmlNormalizer = newViewProps.useHtmlNormalizer;
}

// linkOnPaste
if (newViewProps.linkOnPaste != oldViewProps.linkOnPaste) {
linkOnPaste = newViewProps.linkOnPaste;
}

// textShortcuts
bool textShortcutsChanged =
newViewProps.textShortcuts.size() != oldViewProps.textShortcuts.size();
Expand Down Expand Up @@ -1523,23 +1528,55 @@ - (void)addLinkAt:(NSInteger)start
end:(NSInteger)end
text:(NSString *)text
url:(NSString *)url {
[self tryAddLinkAt:start end:end text:text url:url];
}

- (BOOL)tryAddLinkAt:(NSInteger)start
end:(NSInteger)end
text:(NSString *)text
url:(NSString *)url {
LinkStyle *linkStyleClass = (LinkStyle *)stylesDict[@([LinkStyle getType])];
if (linkStyleClass == nullptr) {
return;
return NO;
}

// translate the output start-end notation to range
NSRange linkRange = NSMakeRange(start, end - start);
if ([StyleUtils handleStyleBlocksAndConflicts:[LinkStyle getType]
range:linkRange
forHost:self]) {
LinkData *linkData = [[LinkData alloc] init];
linkData.text = text;
linkData.url = url;
linkData.isManual = YES;
[linkStyleClass addLink:linkData range:linkRange withSelection:YES];
[self anyTextMayHaveBeenModified];
if (![StyleUtils handleStyleBlocksAndConflicts:[LinkStyle getType]
range:linkRange
forHost:self]) {
return NO;
}

LinkData *linkData = [[LinkData alloc] init];
linkData.text = text;
linkData.url = url;
linkData.isManual = YES;
[linkStyleClass addLink:linkData range:linkRange withSelection:YES];
[self anyTextMayHaveBeenModified];
return YES;
}

// Returns a normalized href when the whole string is a single URL matching
// the link regex config; used by the linkOnPaste behavior.
- (NSString *)linkURLIfEntireString:(NSString *)text {
if (text.length == 0) {
return nullptr;
}

if (![LinkStyle matchesEntireLinkRegexWithConfig:text config:config]) {
return nullptr;
}

NSStringCompareOptions prefixOpts =
NSCaseInsensitiveSearch | NSAnchoredSearch;
if ([text rangeOfString:@"http://" options:prefixOpts].location !=
NSNotFound ||
[text rangeOfString:@"https://" options:prefixOpts].location !=
NSNotFound) {
return text;
}
return [@"https://" stringByAppendingString:text];
}

- (void)removeLinkAt:(NSInteger)start end:(NSInteger)end {
Expand Down
Loading
Loading