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
1 change: 0 additions & 1 deletion .playwright/helpers/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ export async function pastePlainTextIntoEditor(
text: string
): Promise<void> {
const pm = editorInnerLocator.locator('.ProseMirror');
await pm.click();
await pm.evaluate((el, t) => {
const dt = new DataTransfer();
dt.setData('text/plain', t);
Expand Down
70 changes: 70 additions & 0 deletions .playwright/tests/links.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,76 @@ test.describe('test-links copy-paste', () => {
});
});

test.describe('test-links applyLinkOnPaste', () => {
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 pastePlainTextIntoEditor(
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('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 pastePlainTextIntoEditor(
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 pastePlainTextIntoEditor(
page.locator(sel.editorInner),
'https://example.com'
);

// With no selection applyLinkOnPaste 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 @@ -127,6 +127,7 @@ class EnrichedTextInputView :
var shouldEmitOnChangeText: Boolean = false
var experimentalSynchronousEvents: Boolean = false
var useHtmlNormalizer: Boolean = false
var applyLinkOnPaste: Boolean = false

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

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

val pastedSpannable: Spannable =
when {
item.htmlText != null -> {
Expand Down Expand Up @@ -405,6 +410,41 @@ 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 applyLinkOnPaste prop).
private fun linkifySelectionOnPaste(
currentText: Spannable,
start: Int,
end: Int,
item: ClipData.Item,
): Boolean {
val regex = linkRegex ?: return false
val pasted = item.text?.toString()?.trim() ?: return false

if (pasted.isEmpty() || pasted.any { it.isWhitespace() }) {
return false
}

if (!regex.matcher(pasted).matches()) return false
Comment thread
hejsztynx marked this conversation as resolved.

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

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

fun requestFocusProgrammatically() {
requestFocus()
inputMethodManager?.showSoftInput(this, 0)
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 setApplyLinkOnPaste(
view: EnrichedTextInputView?,
value: Boolean,
) {
view?.applyLinkOnPaste = 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 @@ -289,6 +289,7 @@ function App() {
mentionIndicators={['@', '#']}
htmlStyle={WEB_DEFAULT_HTML_STYLE}
linkRegex={LINK_REGEX}
applyLinkOnPaste
sanitizationConfig={SANITIZATION_CONFIG}
textShortcuts={[
{ trigger: '++', style: 'center' },
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 @@ -86,6 +86,7 @@ export function TestLinks() {
: undefined
}
linkRegex={appliedLinkRegex}
applyLinkOnPaste
/>
</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}
applyLinkOnPaste
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}
applyLinkOnPaste
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/docs/api-reference/enriched-text-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,16 @@ The recognized mention indicators. Each item must be a 1-character string. See
| ---------- | ------- | ----------------- |
| `string[]` | `['@']` | Android, iOS, Web |

### `applyLinkOnPaste` {#applylinkonpaste}

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 matches [`linkRegex`](#linkregex). 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 a conflicting style). Has no effect when link detection is disabled with `linkRegex={null}`.

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

### `linkRegex` {#linkregex}

A custom regex pattern for detecting links in the input. If not provided, a
Expand Down
6 changes: 6 additions & 0 deletions ios/EnrichedTextInputView.h
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,19 @@ NS_ASSUME_NONNULL_BEGIN
NSArray<NSDictionary *> *textShortcuts;
@public
BOOL preserveTypingAttributesOnNextEmptyCheck;
@public
BOOL applyLinkOnPaste;
}
- (CGSize)measureSize:(CGFloat)maxWidth;
- (void)emitOnLinkDetectedEvent:(LinkData *)linkData range:(NSRange)range;
- (void)emitOnMentionEvent:(NSString *)indicator text:(nullable NSString *)text;
- (void)emitOnPasteImagesEvent:(NSArray<NSDictionary *> *)images;
- (void)anyTextMayHaveBeenModified;
- (void)scheduleRelayoutIfNeeded;
- (BOOL)addLinkAt:(NSInteger)start
end:(NSInteger)end
text:(NSString *)text
url:(NSString *)url;

@end

Expand Down
31 changes: 20 additions & 11 deletions ios/EnrichedTextInputView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,11 @@ - (void)updateProps:(Props::Shared const &)props
useHtmlNormalizer = newViewProps.useHtmlNormalizer;
}

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

// textShortcuts
bool textShortcutsChanged =
newViewProps.textShortcuts.size() != oldViewProps.textShortcuts.size();
Expand Down Expand Up @@ -1520,27 +1525,31 @@ - (void)toggleCheckboxList:(BOOL)checked {
}
}

- (void)addLinkAt:(NSInteger)start
// return value informs us whether the link has been properly added or not
- (BOOL)addLinkAt:(NSInteger)start
Comment thread
hejsztynx marked this conversation as resolved.
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;
}

- (void)removeLinkAt:(NSInteger)start end:(NSInteger)end {
Expand Down
63 changes: 59 additions & 4 deletions ios/enrichedInputTextView/EnrichedInputTextView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,31 @@ - (void)paste:(id)sender {
return;
}

// applyLinkOnPaste: pasting a bare URL over selected text turns the selection
// into a link pointing to that URL instead of replacing it.
if (typedInput->applyLinkOnPaste && currentRange.length > 0) {
NSCharacterSet *whitespace =
[NSCharacterSet whitespaceAndNewlineCharacterSet];
NSString *candidate = [[self plainTextIn:pasteboard]
stringByTrimmingCharactersInSet:whitespace];
NSString *linkUrl =
[self textMatchesLinkRegex:candidate] ? candidate : nullptr;

if (linkUrl != nullptr) {
NSString *selectedText = [typedInput->textView.textStorage.string
substringWithRange:currentRange];

if ([selectedText stringByTrimmingCharactersInSet:whitespace].length >
0 &&
[typedInput addLinkAt:currentRange.location
end:NSMaxRange(currentRange)
text:selectedText
url:linkUrl]) {
return;
}
}
}

if ([pasteboardTypes containsObject:UTTypeHTML.identifier]) {
// we try processing the html contents

Expand Down Expand Up @@ -226,6 +251,30 @@ - (void)paste:(id)sender {
[typedInput anyTextMayHaveBeenModified];
}

- (BOOL)textMatchesLinkRegex:(NSString *)text {
if (text.length == 0) {
return false;
}

NSRange whitespaceRange =
[text rangeOfCharacterFromSet:[NSCharacterSet
whitespaceAndNewlineCharacterSet]];
if (whitespaceRange.location != NSNotFound) {
return false;
}

EnrichedTextInputView *input = (EnrichedTextInputView *)_input;
if (input == nullptr) {
return false;
}

if (![LinkStyle matchesLinkRegexWithConfig:text config:input.config]) {
return false;
}

return true;
}

- (NSDictionary *)detectImageFormat:(NSString *)type {
if ([type isEqual:UTTypeJPEG.identifier]) {
return @{@"ext" : @"jpg", @"mime" : @"image/jpeg"};
Expand Down Expand Up @@ -270,15 +319,13 @@ - (NSString *)saveToTempFile:(NSData *)data extension:(NSString *)ext {
return nil;
}

- (void)tryHandlingPlainTextItemsIn:(UIPasteboard *)pasteboard
range:(NSRange)range
input:(EnrichedTextInputView *)input {
- (NSString *)plainTextIn:(UIPasteboard *)pasteboard {
NSArray *existingTypes = pasteboard.pasteboardTypes;
NSArray *handledTypes = @[
UTTypeUTF8PlainText.identifier, UTTypePlainText.identifier,
Comment thread
jtatar marked this conversation as resolved.
UTTypeURL.identifier
];
NSString *plainText;
NSString *plainText = nil;

for (NSString *type in handledTypes) {
if (![existingTypes containsObject:type]) {
Expand All @@ -297,6 +344,14 @@ - (void)tryHandlingPlainTextItemsIn:(UIPasteboard *)pasteboard
}
}

return plainText;
}

- (void)tryHandlingPlainTextItemsIn:(UIPasteboard *)pasteboard
range:(NSRange)range
input:(EnrichedTextInputView *)input {
NSString *plainText = [self plainTextIn:pasteboard];

if (!plainText) {
return;
}
Expand Down
2 changes: 2 additions & 0 deletions src/native/EnrichedTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const EnrichedTextInput = ({
autoCapitalize = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.autoCapitalize,
htmlStyle = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.htmlStyle,
linkRegex: _linkRegex,
applyLinkOnPaste = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.applyLinkOnPaste,
onFocus,
onBlur,
onChangeText,
Expand Down Expand Up @@ -342,6 +343,7 @@ export const EnrichedTextInput = ({
autoCapitalize={autoCapitalize}
htmlStyle={normalizedHtmlStyle}
linkRegex={linkRegex}
applyLinkOnPaste={applyLinkOnPaste}
onInputFocus={onFocus}
onInputBlur={onBlur}
onChangeText={onChangeText}
Expand Down
Loading