diff --git a/.playwright/helpers/clipboard.ts b/.playwright/helpers/clipboard.ts index 5e1dc5b54..ec0924711 100644 --- a/.playwright/helpers/clipboard.ts +++ b/.playwright/helpers/clipboard.ts @@ -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 { + 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); +} diff --git a/.playwright/tests/links.spec.ts b/.playwright/tests/links.spec.ts index 0300131ed..a43e1ec31 100644 --- a/.playwright/tests/links.spec.ts +++ b/.playwright/tests/links.spec.ts @@ -10,6 +10,7 @@ import { copyWholeContent, pasteIntoWholeContent, pastePlainTextIntoEditor, + pastePlainTextOverSelection, } from '../helpers/clipboard'; test.setTimeout(90_000); @@ -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 { + 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 pastePlainTextOverSelection( + page.locator(sel.editorInner), + 'https://example.com' + ); + + await expect + .poll(async () => getTestLinksSerializedHtml(page)) + .toContain('

Hello world

'); + }); + + test('prefixes https:// for a scheme-less URL', async ({ page }) => { + await gotoTestLinks(page); + await setTestLinksEditorHtml(page, '

Hello world

'); + await selectRange(page, 6, 11); + + await pastePlainTextOverSelection( + page.locator(sel.editorInner), + 'www.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 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'); + }); + + 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 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'); + }); +}); + 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 023cc20fc..12abed1e3 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -120,6 +120,10 @@ 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) @@ -127,6 +131,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 +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 -> { @@ -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) @@ -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 } @@ -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 } } 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 245895ada..74b2da64f 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -282,6 +282,7 @@ function App() { mentionIndicators={['@', '#']} htmlStyle={WEB_DEFAULT_HTML_STYLE} linkRegex={LINK_REGEX} + linkOnPaste sanitizationConfig={SANITIZATION_CONFIG} /> diff --git a/apps/example/src/screens/DevScreen.tsx b/apps/example/src/screens/DevScreen.tsx index d5b4ba3f1..ace29bf35 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/INPUT_API_REFERENCE.md b/docs/INPUT_API_REFERENCE.md index 12d1341b3..e7f833546 100644 --- a/docs/INPUT_API_REFERENCE.md +++ b/docs/INPUT_API_REFERENCE.md @@ -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). diff --git a/ios/EnrichedTextInputView.h b/ios/EnrichedTextInputView.h index 4fde33a47..ea9923f71 100644 --- a/ios/EnrichedTextInputView.h +++ b/ios/EnrichedTextInputView.h @@ -35,12 +35,19 @@ NS_ASSUME_NONNULL_BEGIN BOOL blockEmitting; @public BOOL useHtmlNormalizer; +@public + BOOL linkOnPaste; @public NSValue *dotReplacementRange; @public NSArray *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 *)images; diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index a899aa629..fb76fc232 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -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(); @@ -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 { diff --git a/ios/enrichedInputTextView/EnrichedInputTextView.mm b/ios/enrichedInputTextView/EnrichedInputTextView.mm index 4e8dd50ba..15766ba7f 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 linkURLIfEntireString:candidate] + : nullptr; + + if (linkUrl != nullptr) { + NSString *selectedText = [typedInput->textView.textStorage.string + substringWithRange:currentRange]; + + if ([selectedText stringByTrimmingCharactersInSet:whitespace].length > + 0 && + [typedInput tryAddLinkAt: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/ios/interfaces/StyleHeaders.h b/ios/interfaces/StyleHeaders.h index aca5d4a3c..d414e7360 100644 --- a/ios/interfaces/StyleHeaders.h +++ b/ios/interfaces/StyleHeaders.h @@ -32,6 +32,8 @@ - (void)applyLinkMetaWithData:(LinkData *)linkData range:(NSRange)range; + (BOOL)matchesLinkRegexWithConfig:(NSString *)url config:(EnrichedConfig *)config; ++ (BOOL)matchesEntireLinkRegexWithConfig:(NSString *)url + config:(EnrichedConfig *)config; @end @interface MentionStyle : StyleBase diff --git a/ios/styles/LinkStyle.mm b/ios/styles/LinkStyle.mm index 1eae1a1d1..cd0a2383d 100644 --- a/ios/styles/LinkStyle.mm +++ b/ios/styles/LinkStyle.mm @@ -381,6 +381,32 @@ + (BOOL)matchesLinkRegexWithConfig:(NSString *)url return [userRegex numberOfMatchesInString:url options:0 range:range] > 0; } ++ (BOOL)matchesEntireLinkRegexWithConfig:(NSString *)url + config:(EnrichedConfig *)config { + LinkRegexConfig *linkRegexConfig = [config linkRegexConfig]; + if (linkRegexConfig == nullptr || linkRegexConfig.isDisabled) { + return NO; + } + NSRange range = NSMakeRange(0, url.length); + BOOL (^matchesWholeString)(NSRegularExpression *) = + ^BOOL(NSRegularExpression *regex) { + if (regex == nullptr) { + return NO; + } + NSRange match = [regex rangeOfFirstMatchInString:url + options:0 + range:range]; + return match.location == 0 && match.length == url.length; + }; + NSRegularExpression *userRegex = [config parsedLinkRegex]; + if (linkRegexConfig.isDefault || userRegex == nullptr) { + return matchesWholeString([self fullRegex]) || + matchesWholeString([self wwwRegex]) || + matchesWholeString([self bareRegex]); + } + return matchesWholeString(userRegex); +} + // handles refreshing manual links - (void)handleManualLinks:(NSString *)word inRange:(NSRange)wordRange { // look for manual links within the word 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 20e9d12b4..b7c5241e3 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -371,6 +371,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 d4d0dfd8b..f24449178 100644 --- a/src/types.ts +++ b/src/types.ts @@ -670,6 +670,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 692dec9e7..ceae8b918 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -69,6 +69,7 @@ import { MergeAdjacentSameKindBlocksPlugin } from './pmPlugins/MergeAdjacentSame import { OrderedListMarkerWidthPlugin } from './pmPlugins/OrderedListMarkerWidthPlugin'; import { StripMarksInCodeBlockPlugin } from './pmPlugins/StripMarksInCodeBlockPlugin'; import { handleClipboardPasteImages } from './pasteImages'; +import { handleLinkOnPaste } from './linkOnPaste'; import { MentionPlugin, setMention, @@ -125,6 +126,7 @@ export const EnrichedTextInput = ({ onChangeMention, onEndMention, linkRegex, + linkOnPaste = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.linkOnPaste, htmlStyle, useHtmlNormalizer = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.useHtmlNormalizer, sanitizationConfig, @@ -162,6 +164,7 @@ export const EnrichedTextInput = ({ const onSubmitEditingRef = useStableRef(onSubmitEditing); const onKeyPressRef = useStableRef(onKeyPress); const useHtmlNormalizerRef = useStableRef(useHtmlNormalizer); + const linkOnPasteRef = useStableRef(linkOnPaste); const sanitizationConfigRef = useStableRef(sanitizationConfig); const mentionCallbacksRef = useStableRef(mentionCallbacks); const textShortcutsRef = useStableRef(textShortcuts); @@ -283,6 +286,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/linkOnPaste.ts b/src/web/linkOnPaste.ts new file mode 100644 index 000000000..da14d3829 --- /dev/null +++ b/src/web/linkOnPaste.ts @@ -0,0 +1,66 @@ +/** + * 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'; + +/** + * Returns a normalized href when the whole string is a single 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 /^https?:\/\//i.test(text) ? text : `https://${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 = editor.state.doc.textBetween(from, to, ' '); + if (selectedText.trim().length === 0) return false; + + // setLink is overridden in EnrichedLink to bail out when the link style is + // blocked (e.g. inside inline code or a code block); a `false` run result + // falls through to the default paste handling. + if (!editor.chain().setLink({ href }).run()) { + return false; + } + + event.preventDefault(); + editor.commands.setTextSelection(to); + return true; +}