diff --git a/.playwright/helpers/clipboard.ts b/.playwright/helpers/clipboard.ts index 5e1dc5b54..dc4ebdc00 100644 --- a/.playwright/helpers/clipboard.ts +++ b/.playwright/helpers/clipboard.ts @@ -39,7 +39,6 @@ export async function pastePlainTextIntoEditor( text: string ): Promise { const pm = editorInnerLocator.locator('.ProseMirror'); - await pm.click(); await pm.evaluate((el, t) => { const dt = new DataTransfer(); dt.setData('text/plain', t); diff --git a/.playwright/tests/links.spec.ts b/.playwright/tests/links.spec.ts index 12329a338..575337c09 100644 --- a/.playwright/tests/links.spec.ts +++ b/.playwright/tests/links.spec.ts @@ -589,6 +589,76 @@ test.describe('test-links copy-paste', () => { }); }); +test.describe('test-links linkOnPaste', () => { + async function selectRange( + page: Page, + start: number, + end: number + ): Promise { + 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, '

Hello world

'); + await selectRange(page, 6, 11); + + await pastePlainTextIntoEditor( + page.locator(sel.editorInner), + 'https://example.com' + ); + + await expect + .poll(async () => getTestLinksSerializedHtml(page)) + .toContain('

Hello world

'); + }); + + test('does not linkify the selection when the pasted text is not a bare URL', async ({ + page, + }) => { + await gotoTestLinks(page); + await setTestLinksEditorHtml(page, '

Hello world

'); + 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'); + }); + + test('does not linkify existing text when there is no selection', async ({ + page, + }) => { + await gotoTestLinks(page); + await setTestLinksEditorHtml(page, '

Hello

'); + await selectRange(page, 5, 5); + + await pastePlainTextIntoEditor( + 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'); + }); +}); + test.describe('test-links manual link editing', () => { test('typing inside a manual link keeps the link covering the typed text', async ({ page, diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt index f4aed71ea..7a8978028 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -127,6 +127,7 @@ class EnrichedTextInputView : var shouldEmitOnChangeText: Boolean = false var experimentalSynchronousEvents: Boolean = false var useHtmlNormalizer: Boolean = false + var linkOnPaste: Boolean = false // Pair: (trigger, style) var textShortcuts: List> = emptyList() @@ -376,6 +377,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 -> { @@ -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 linkOnPaste 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 + + 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) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt index 9dcbb8244..60608e3c6 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -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, diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx index a16c0e7a4..fafc32b89 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -289,6 +289,7 @@ function App() { mentionIndicators={['@', '#']} htmlStyle={WEB_DEFAULT_HTML_STYLE} linkRegex={LINK_REGEX} + linkOnPaste sanitizationConfig={SANITIZATION_CONFIG} textShortcuts={[ { trigger: '++', style: 'center' }, diff --git a/apps/example-web/src/testScreens/TestLinks.tsx b/apps/example-web/src/testScreens/TestLinks.tsx index 745e9c912..2d4de7b0b 100644 --- a/apps/example-web/src/testScreens/TestLinks.tsx +++ b/apps/example-web/src/testScreens/TestLinks.tsx @@ -86,6 +86,7 @@ export function TestLinks() { : undefined } linkRegex={appliedLinkRegex} + linkOnPaste /> diff --git a/apps/example/src/screens/DevScreen.tsx b/apps/example/src/screens/DevScreen.tsx index 3cf719b7d..77de8fd0f 100644 --- a/apps/example/src/screens/DevScreen.tsx +++ b/apps/example/src/screens/DevScreen.tsx @@ -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)} diff --git a/apps/example/src/screens/TestScreen.tsx b/apps/example/src/screens/TestScreen.tsx index 160fda327..5f3ff4f2a 100644 --- a/apps/example/src/screens/TestScreen.tsx +++ b/apps/example/src/screens/TestScreen.tsx @@ -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)} diff --git a/docs/docs/api-reference/enriched-text-input.md b/docs/docs/api-reference/enriched-text-input.md index 3dfa6802b..a8eebdbd1 100644 --- a/docs/docs/api-reference/enriched-text-input.md +++ b/docs/docs/api-reference/enriched-text-input.md @@ -205,6 +205,16 @@ The recognized mention indicators. Each item must be a 1-character string. See | ---------- | ------- | ----------------- | | `string[]` | `['@']` | Android, iOS, Web | +### `linkOnPaste` {#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 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 diff --git a/ios/EnrichedTextInputView.h b/ios/EnrichedTextInputView.h index 398b879df..e066d4a35 100644 --- a/ios/EnrichedTextInputView.h +++ b/ios/EnrichedTextInputView.h @@ -35,6 +35,8 @@ NS_ASSUME_NONNULL_BEGIN BOOL blockEmitting; @public BOOL useHtmlNormalizer; +@public + BOOL linkOnPaste; @public NSValue *dotReplacementRange; @public @@ -43,6 +45,11 @@ NS_ASSUME_NONNULL_BEGIN BOOL preserveTypingAttributesOnNextEmptyCheck; } - (CGSize)measureSize:(CGFloat)maxWidth; +- (BOOL)addLinkAt:(NSInteger)start + end:(NSInteger)end + text:(NSString *)text + url:(NSString *)url; +- (nullable NSString *)linkTextIfMatchesLinkRegex:(NSString *)text; - (void)emitOnLinkDetectedEvent:(LinkData *)linkData range:(NSRange)range; - (void)emitOnMentionEvent:(NSString *)indicator text:(nullable NSString *)text; - (void)emitOnPasteImagesEvent:(NSArray *)images; diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index b255b0e93..39638c332 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -694,6 +694,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(); @@ -1520,27 +1525,49 @@ - (void)toggleCheckboxList:(BOOL)checked { } } -- (void)addLinkAt:(NSInteger)start +- (BOOL)addLinkAt:(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; +} + +- (NSString *)linkTextIfMatchesLinkRegex:(NSString *)text { + if (text.length == 0) { + return nullptr; } + + NSRange whitespaceRange = + [text rangeOfCharacterFromSet:[NSCharacterSet + whitespaceAndNewlineCharacterSet]]; + if (whitespaceRange.location != NSNotFound) { + return nullptr; + } + + if (![LinkStyle matchesLinkRegexWithConfig:text config:config]) { + return nullptr; + } + + return text; } - (void)removeLinkAt:(NSInteger)start end:(NSInteger)end { diff --git a/ios/enrichedInputTextView/EnrichedInputTextView.mm b/ios/enrichedInputTextView/EnrichedInputTextView.mm index 4e8dd50ba..f8e550b24 100644 --- a/ios/enrichedInputTextView/EnrichedInputTextView.mm +++ b/ios/enrichedInputTextView/EnrichedInputTextView.mm @@ -178,6 +178,32 @@ - (void)paste:(id)sender { return; } + // linkOnPaste: pasting a bare URL over selected text turns the selection + // into a link pointing to that URL instead of replacing it. + if (typedInput->linkOnPaste && currentRange.length > 0) { + NSCharacterSet *whitespace = + [NSCharacterSet whitespaceAndNewlineCharacterSet]; + NSString *candidate = [[self plainTextIn:pasteboard] + stringByTrimmingCharactersInSet:whitespace]; + NSString *linkUrl = candidate.length > 0 + ? [typedInput linkTextIfMatchesLinkRegex: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 @@ -261,15 +287,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, UTTypeURL.identifier ]; - NSString *plainText; + NSString *plainText = nil; for (NSString *type in handledTypes) { if (![existingTypes containsObject:type]) { @@ -288,6 +312,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; } diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx index feffab4cc..310544bbf 100644 --- a/src/native/EnrichedTextInput.tsx +++ b/src/native/EnrichedTextInput.tsx @@ -60,6 +60,7 @@ export const EnrichedTextInput = ({ autoCapitalize = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.autoCapitalize, htmlStyle = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.htmlStyle, linkRegex: _linkRegex, + linkOnPaste = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.linkOnPaste, onFocus, onBlur, onChangeText, @@ -342,6 +343,7 @@ export const EnrichedTextInput = ({ autoCapitalize={autoCapitalize} htmlStyle={normalizedHtmlStyle} linkRegex={linkRegex} + linkOnPaste={linkOnPaste} onInputFocus={onFocus} onInputBlur={onBlur} onChangeText={onChangeText} diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index 06990e393..70bc6866a 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -369,6 +369,7 @@ export interface NativeProps extends ViewProps { htmlStyle?: HtmlStyleInternal; scrollEnabled?: boolean; linkRegex?: LinkNativeRegex; + linkOnPaste?: boolean; contextMenuItems?: ReadonlyArray>; textShortcuts: ReadonlyArray>; returnKeyType?: string; diff --git a/src/types.ts b/src/types.ts index 1e9d891ec..b0ce6a484 100644 --- a/src/types.ts +++ b/src/types.ts @@ -688,6 +688,15 @@ export interface EnrichedTextInputProps extends Omit { */ linkRegex?: RegExp | null; + /** + * If `true`, pasting clipboard content that consists solely of a URL over + * a non-empty selection turns the selected text into a link pointing to + * that URL instead of replacing the selection with the pasted text. + * Has no effect when link detection is disabled via `linkRegex={null}`. + * Disabled by default. + */ + linkOnPaste?: boolean; + /** The label shown on the return key of the software keyboard. */ returnKeyType?: ReturnKeyTypeOptions; diff --git a/src/utils/EnrichedTextInputDefaultProps.ts b/src/utils/EnrichedTextInputDefaultProps.ts index 0cf2b3170..d49f9e31d 100644 --- a/src/utils/EnrichedTextInputDefaultProps.ts +++ b/src/utils/EnrichedTextInputDefaultProps.ts @@ -6,6 +6,7 @@ export const ENRICHED_TEXT_INPUT_DEFAULT_PROPS = { htmlStyle: {}, autoCapitalize: 'sentences', scrollEnabled: true, + linkOnPaste: false, androidExperimentalSynchronousEvents: false, useHtmlNormalizer: true, allowFontScaling: true, diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index 1002b647c..e1b2177b8 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -68,6 +68,7 @@ import { StrictMarksPlugin } from './pmPlugins/StrictMarksPlugin'; import { MergeAdjacentSameKindBlocksPlugin } from './pmPlugins/MergeAdjacentSameKindBlocksPlugin'; import { OrderedListMarkerWidthPlugin } from './pmPlugins/OrderedListMarkerWidthPlugin'; import { StripMarksInCodeBlockPlugin } from './pmPlugins/StripMarksInCodeBlockPlugin'; +import { handleLinkOnPaste } from './utils/linkOnPaste'; import { handleClipboardPasteImages } from './utils/pasteImages'; import { MentionPlugin, @@ -131,6 +132,7 @@ export const EnrichedTextInput = ({ onChangeMention, onEndMention, linkRegex, + linkOnPaste = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.linkOnPaste, htmlStyle, useHtmlNormalizer = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.useHtmlNormalizer, sanitizationConfig, @@ -169,6 +171,7 @@ export const EnrichedTextInput = ({ const onKeyPressRef = useStableRef(onKeyPress); const onLinkPressRef = useStableRef(onLinkPress); const useHtmlNormalizerRef = useStableRef(useHtmlNormalizer); + const linkOnPasteRef = useStableRef(linkOnPaste); const sanitizationConfigRef = useStableRef(sanitizationConfig); const mentionCallbacksRef = useStableRef(mentionCallbacks); const textShortcutsRef = useStableRef(textShortcuts); @@ -300,6 +303,12 @@ export const EnrichedTextInput = ({ event, () => editorInstanceRef.current, () => onPasteImagesRef.current + ) || + handleLinkOnPaste( + event, + () => editorInstanceRef.current, + () => linkOnPasteRef.current, + () => linkEmitterRef.current.linkRegex ), attributes: { autoCapitalize, diff --git a/src/web/formats/EnrichedLink.ts b/src/web/formats/EnrichedLink.ts index e4b9920d9..b2feebc88 100644 --- a/src/web/formats/EnrichedLink.ts +++ b/src/web/formats/EnrichedLink.ts @@ -140,14 +140,14 @@ export function setLink( end: number, text: string, url: string -) { +): boolean { const { state } = editor; const doc = state.doc; const from = nativePosToTiptapPos(doc, start); const to = nativePosToTiptapPos(doc, end); if (isRangeLinkBlocked(editor, from, to)) { - return; + return false; } if (text.length === 0 && from !== to) { @@ -155,13 +155,13 @@ export function setLink( } if (text.length === 0 || url.length === 0) { - return; + return false; } const linkType = state.schema.marks.link; - if (!linkType) return; + if (!linkType) return false; const linkMark = linkType.create({ href: url }); - editor + return editor .chain() .focus() .command(({ tr, state: s }) => { diff --git a/src/web/utils/linkOnPaste.ts b/src/web/utils/linkOnPaste.ts new file mode 100644 index 000000000..e3965832d --- /dev/null +++ b/src/web/utils/linkOnPaste.ts @@ -0,0 +1,69 @@ +/** + * The `linkOnPaste` behavior: pasting clipboard content that consists solely + * of a URL over a non-empty selection turns the selection into a link + * pointing to that URL instead of replacing it. + */ + +import type { Editor } from '@tiptap/react'; + +import { findAutolinkRangesInWord } from '../pmPlugins/AutolinkPlugin/autolinkRegex'; +import { setLink } from '../formats/EnrichedLink'; +import { + nativeLeafText, + tiptapPosToNativePos, +} from '../nativeMappers/positionMapping'; + +/** + * Returns a href when the whole string is a single word URL matching + * the configured link regex, `null` otherwise. `linkRegex === null` means + * link detection is disabled. + */ +function linkUrlIfEntireString( + text: string, + linkRegex: RegExp | null | undefined +): string | null { + if (linkRegex === null || text.length === 0) { + return null; + } + + const ranges = findAutolinkRangesInWord(text, linkRegex); + const isFullMatch = ranges.some( + (r) => r.start === 0 && r.endExclusive === text.length + ); + if (!isFullMatch) { + return null; + } + + return text; +} + +export function handleLinkOnPaste( + event: ClipboardEvent, + getEditor: () => Editor | null, + getLinkOnPaste: () => boolean | undefined, + getLinkRegex: () => RegExp | null | undefined +): boolean { + if (!getLinkOnPaste()) return false; + + const editor = getEditor(); + if (!editor) return false; + + const { from, to } = editor.state.selection; + if (from === to) return false; + + const pasted = event.clipboardData?.getData('text/plain').trim() ?? ''; + const href = linkUrlIfEntireString(pasted, getLinkRegex()); + if (!href) return false; + + const selectedText = nativeLeafText(editor.state.doc, from, to); + if (selectedText.trim().length === 0) return false; + + const nativeFrom = tiptapPosToNativePos(editor.state.doc, from); + const nativeTo = tiptapPosToNativePos(editor.state.doc, to); + + if (!setLink(editor, nativeFrom, nativeTo, selectedText, href)) return false; + + event.preventDefault(); + editor.commands.setTextSelection(to); + return true; +}