diff --git a/cspell.json b/cspell.json index 5bd564724e21..6b633a96560a 100644 --- a/cspell.json +++ b/cspell.json @@ -759,7 +759,9 @@ "مثال", "moveElemsAttrsToGroup", "removeHiddenElems", - "moveGroupAttrsToElems" + "moveGroupAttrsToElems", + "mple", + "Selec" ], "ignorePaths": [ "src/languages/de.ts", diff --git a/src/components/RenderHTML.tsx b/src/components/RenderHTML.tsx index d157f822b768..870e81cbb230 100644 --- a/src/components/RenderHTML.tsx +++ b/src/components/RenderHTML.tsx @@ -17,6 +17,9 @@ function RenderHTML({html: htmlParam}: RenderHTMLProps) { const html = useMemo(() => { return ( Parser.replace(htmlParam, {shouldEscapeText: false, filterRules: ['emoji']}) + // Escape brackets when pasting a link, since unescaped [] can break Markdown link syntax + .replace(/&#91;/g, '[') + .replace(/&#93;/g, ']') // Remove double tag if exists and keep the outermost tag (always the original tag). .replace(/(]*>)(?:]*>)+/g, '$1') .replace(/(<\/emoji[^>]*>)(?:<\/emoji[^>]*>)+/g, '$1') diff --git a/src/hooks/useHtmlPaste/index.ts b/src/hooks/useHtmlPaste/index.ts index fcf40cc35e1d..0c119157570a 100644 --- a/src/hooks/useHtmlPaste/index.ts +++ b/src/hooks/useHtmlPaste/index.ts @@ -1,4 +1,5 @@ import {useCallback, useEffect, useRef} from 'react'; +import {isStandaloneURL, toMarkdownLink} from '@libs/MarkdownLinkHelpers'; import Parser from '@libs/Parser'; import CONST from '@src/CONST'; import type UseHtmlPaste from './types'; @@ -101,15 +102,24 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, isActive /** * Paste the plaintext content into Composer. - * - * @param {ClipboardEvent} event + * If the clipboard contains a single URL and there is selected text, wrap the selected text in a markdown link. */ const handlePastePlainText = useCallback( (event: ClipboardEvent) => { - const plainText = event.clipboardData?.getData('text/plain'); - if (plainText) { - paste(plainText); + const clipboardText = event.clipboardData?.getData('text/plain')?.trim(); + if (!clipboardText) { + return; + } + + const selection = window.getSelection?.(); + const selectedText = selection?.toString() ?? ''; + + if (isStandaloneURL(clipboardText) && selectedText) { + paste(toMarkdownLink(selectedText, clipboardText)); + return; } + + paste(clipboardText); }, [paste], ); @@ -184,6 +194,10 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, isActive document.removeEventListener('paste', listener, true); }; }, [isActive]); + + return { + handlePastePlainText, + }; }; export default useHtmlPaste; diff --git a/src/hooks/useHtmlPaste/types.ts b/src/hooks/useHtmlPaste/types.ts index 65778463f80e..c8d8e9e6ba9b 100644 --- a/src/hooks/useHtmlPaste/types.ts +++ b/src/hooks/useHtmlPaste/types.ts @@ -1,11 +1,13 @@ -import type {MutableRefObject} from 'react'; +import type {RefObject} from 'react'; import type {TextInput} from 'react-native'; type UseHtmlPaste = ( - textInputRef: MutableRefObject<(HTMLTextAreaElement & TextInput) | TextInput | null>, + textInputRef: RefObject<(HTMLTextAreaElement & TextInput) | TextInput | null>, preHtmlPasteCallback?: (event: ClipboardEvent) => boolean, isActive?: boolean, maxLength?: number, // Maximum length of the text input value after pasting -) => void; +) => void | { + handlePastePlainText: (event: ClipboardEvent) => void; +}; export default UseHtmlPaste; diff --git a/src/libs/MarkdownLinkHelpers.ts b/src/libs/MarkdownLinkHelpers.ts new file mode 100644 index 000000000000..20e20f1c27ce --- /dev/null +++ b/src/libs/MarkdownLinkHelpers.ts @@ -0,0 +1,95 @@ +import {Str} from 'expensify-common'; + +/** + * Returns true if `text` is a single token URL (no whitespace), allowing optional angle-bracket wrappers. + */ +const isStandaloneURL = (text: string): boolean => { + if (!text) { + return false; + } + const trimmed = text.trim(); + if (/\s/.test(trimmed)) { + return false; + } + const unwrapped = trimmed.replace(/^<|>$/g, ''); + + // Reject if contains emoji or any non-ASCII characters + // (valid URLs per RFC 3986 should be ASCII-only) + // eslint-disable-next-line no-control-regex + if (/[^\x00-\x7F]/.test(unwrapped)) { + return false; + } + + return Str.isValidURL(unwrapped); +}; + +/** + * Collapse newlines to spaces, collapse repeated whitespace to single space, trim, + * and replace opening [ and closing ] square brackets with HTML codes, which would otherwise break Markdown link text. + */ +const escapeLinkText = (text: string): string => { + if (!text) { + return ''; + } + const collapsed = text + .replace(/\r?\n+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + return collapsed.replace(/\[/g, '[').replace(/\]/g, ']'); +}; + +/** + * Sanitize the URL for Markdown link. Remove surrounding < > if present and encode it + * to avoid raw spaces or other invalid characters inside the parentheses. + * We won't alter semantics otherwise. + */ +const sanitizeUrlForMarkdown = (url: string): string => { + if (!isStandaloneURL(url)) { + return url; + } + + const trimmed = (url || '').trim(); + const unwrapped = trimmed.replace(/^<|>$/g, ''); + + try { + return encodeURI(unwrapped); + } catch { + return unwrapped; + } +}; + +/** + * Build a Markdown link: [escaped-selected-text](sanitized-url) + * We do NOT wrap the URL in < > here — angle brackets are only valid for "auto links", not inside link destinations. + */ +const toMarkdownLink = (selectedText: string, url: string): string => { + const safeText = escapeLinkText(selectedText); + const safeUrl = sanitizeUrlForMarkdown(url); + return `[${safeText}](${safeUrl})`; +}; + +type DetectRewriteResult = {text: string | null; didReplace: boolean}; + +/** + * Given prevText and a selection (start/end) and the insertedText (e.g. diff), + * returns a rewritten text if this looks like "selection replaced by a single URL" and we should turn it into a markdown link. + */ +const detectAndRewritePaste = (prevText: string, selectionStart: number, selectionEnd: number, insertedText: string): DetectRewriteResult => { + if (!insertedText || !isStandaloneURL(insertedText)) { + return {text: null, didReplace: false}; + } + + const replacedSelectionLength = Math.max(0, selectionEnd - selectionStart); + if (replacedSelectionLength === 0) { + // nothing replaced (user pasted URL without selecting text) -> don't rewrite + return {text: null, didReplace: false}; + } + + const selectedText = prevText.substring(selectionStart, selectionEnd); + const replacement = toMarkdownLink(selectedText, insertedText); + const newText = prevText.slice(0, selectionStart) + replacement + prevText.slice(selectionEnd); + + return {text: newText, didReplace: true}; +}; + +export {isStandaloneURL, escapeLinkText, sanitizeUrlForMarkdown, toMarkdownLink, detectAndRewritePaste}; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index eb42e95d32eb..71e33efc6596 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -39,6 +39,7 @@ import {containsOnlyEmojis, extractEmojis, getAddedEmojis, getPreferredSkinToneI import focusComposerWithDelay from '@libs/focusComposerWithDelay'; import getPlatform from '@libs/getPlatform'; import {addKeyDownPressListener, removeKeyDownPressListener} from '@libs/KeyboardShortcut/KeyDownPressListener'; +import {detectAndRewritePaste} from '@libs/MarkdownLinkHelpers'; import Parser from '@libs/Parser'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import {isValidReportIDFromPath, shouldAutoFocusOnKeyPress} from '@libs/ReportUtils'; @@ -376,9 +377,30 @@ function ComposerWithSuggestions({ const updateComment = useCallback( (commentValue: string, shouldDebounceSaveComment?: boolean) => { raiseIsScrollLikelyLayoutTriggered(); - const {startIndex, endIndex, diff} = findNewlyAddedChars(lastTextRef.current, commentValue); - const isEmojiInserted = diff.length && endIndex > startIndex && diff.trim() === diff && containsOnlyEmojis(diff); - const commentWithSpaceInserted = isEmojiInserted ? insertWhiteSpaceAtIndex(commentValue, endIndex) : commentValue; + + // previous text before change + const prevText = lastTextRef.current; + // snapshot selection (should be the selection that was active just before the paste/change) + const prevSelectionStart = selection?.start ?? 0; + const prevSelectionEnd = selection?.end ?? 0; + + // detect newly added text (existing helper) + const {startIndex, endIndex, diff} = findNewlyAddedChars(prevText, commentValue); + + // Try to rewrite if this looks like "selected text replaced with a single URL" + const {text: rewritten, didReplace} = detectAndRewritePaste(prevText, prevSelectionStart, prevSelectionEnd, diff); + + // Use the rewritten text when we replaced; otherwise fall back to the original commentValue pipeline + const effectiveCommentValue = didReplace ? (rewritten ?? commentValue) : commentValue; + + // Emoji handling: skip the "emoji inserted" special-case when we performed the markdown rewrite. + const isEmojiInserted = + !didReplace && // <-- use the didReplace flag instead of searching for '[' + diff.length && + endIndex > startIndex && + diff.trim() === diff && + containsOnlyEmojis(diff); + const commentWithSpaceInserted = isEmojiInserted ? insertWhiteSpaceAtIndex(effectiveCommentValue, endIndex) : effectiveCommentValue; const {text: newComment, emojis, cursorPosition} = replaceAndExtractEmojis(commentWithSpaceInserted, preferredSkinTone, preferredLocale); if (emojis.length) { const newEmojis = getAddedEmojis(emojis, emojisPresentBefore.current); @@ -426,7 +448,18 @@ function ComposerWithSuggestions({ debouncedBroadcastUserIsTyping(reportID); } }, - [findNewlyAddedChars, preferredLocale, preferredSkinTone, reportID, setIsCommentEmpty, suggestionsRef, raiseIsScrollLikelyLayoutTriggered, debouncedSaveReportComment, selection.end], + [ + findNewlyAddedChars, + preferredLocale, + preferredSkinTone, + reportID, + setIsCommentEmpty, + suggestionsRef, + raiseIsScrollLikelyLayoutTriggered, + debouncedSaveReportComment, + selection?.end, + selection?.start, + ], ); /** diff --git a/tests/ui/ReportActionComposeTest.tsx b/tests/ui/ReportActionComposeTest.tsx new file mode 100644 index 000000000000..17abb81eb2a8 --- /dev/null +++ b/tests/ui/ReportActionComposeTest.tsx @@ -0,0 +1,305 @@ +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer} from '@react-navigation/native'; +import type NativeNavigation from '@react-navigation/native'; +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; +import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; +import ReportScreen from '@pages/home/ReportScreen'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('expo-image-manipulator', () => ({ + manipulateAsync: jest.fn(), +})); + +jest.mock('expo-image', () => ({ + Image: () => null, +})); + +jest.mock('expo-av', () => ({ + Video: () => null, + ResizeMode: {COVER: 'cover'}, + VideoFullscreenUpdate: {}, +})); +jest.mock('pusher-js', () => ({ + Pusher: { + subscribe: jest.fn(() => ({ + bind: jest.fn(), + unbind: jest.fn(), + })), + unsubscribe: jest.fn(), + }, +})); + +// Mock dependencies +jest.mock('@libs/ReportUtils', () => ({ + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + ...((): typeof import('@libs/ReportUtils') => { + return jest.requireActual('@libs/ReportUtils'); + })(), + canUserPerformWriteAction: jest.fn(() => true), + isChatRoom: jest.fn(() => false), + isGroupChat: jest.fn(() => false), + isSettled: jest.fn(() => false), + isReportApproved: jest.fn(() => false), + isMoneyRequestAction: jest.fn(() => false), + isSentMoneyReportAction: jest.fn(() => false), + isSelfDM: jest.fn(() => false), + getParentReport: jest.fn(() => ({})), + temporaryGetMoneyRequestOptions: jest.fn(() => []), +})); +jest.mock('@react-navigation/native', () => ({ + ...((): typeof NativeNavigation => { + return jest.requireActual('@react-navigation/native'); + })(), + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + addListener: jest.fn(() => jest.fn()), + })), + useIsFocused: jest.fn(() => true), + useRoute: jest.fn(() => ({key: '', name: '', params: {reportID: '1'}})), +})); + +TestHelper.setupGlobalFetchMock(); + +const Stack = createPlatformStackNavigator(); + +// Constants for test data +const REPORT_ID = '1'; +const POLICY_ID = 'policy1'; +const USER_EMAIL = 'user1@example.com'; +const mockReport = { + reportID: REPORT_ID, + policyID: POLICY_ID, + participants: {}, + type: CONST.REPORT.TYPE.CHAT, +}; + +const mockPersonalDetailsList = { + [USER_EMAIL]: { + login: USER_EMAIL, + displayName: 'User One', + firstName: 'User', + lastName: 'One', + }, +}; +const mockReportActions = { + [REPORT_ID]: {}, +}; + +// Helper function to set up Onyx data +const setupOnyxData = async () => { + await act(async () => { + await Onyx.merge(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, true); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, mockReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, null); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, mockPersonalDetailsList); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, mockReportActions[REPORT_ID]); + }); +}; + +// Helper function to render the page +const renderPage = (initialRouteName: typeof SCREENS.REPORT, initialParams: {reportID: string; onSubmit: () => void}) => { + return render( + + + + + + + + + , + ); +}; + +// Helper function to simulate text selection +const simulateSelection = (composer: ReturnType, start: number, end: number) => { + fireEvent(composer, 'selectionChange', { + nativeEvent: {selection: {start, end}}, + }); +}; + +describe('ReportActionCompose Integration Tests', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }); + }); + + beforeEach(async () => { + await setupOnyxData(); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + jest.clearAllMocks(); + }); + + describe('Paste Behavior with Selection updateComment logic', () => { + it('should format pasted URL as Markdown link when text is selected', async () => { + const onSubmit = jest.fn(); + const {unmount} = renderPage(SCREENS.REPORT, {reportID: REPORT_ID, onSubmit}); + await waitForBatchedUpdatesWithAct(); + + const composer = screen.getByTestId('composer'); + fireEvent.changeText(composer, 'Selected text'); + simulateSelection(composer, 0, 8); + fireEvent.changeText(composer, 'https://example.com'); + + await waitFor(() => { + expect(screen.getByTestId('composer').props.value).toBe('[Selected](https://example.com) text'); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should insert raw URL when no text is selected', async () => { + const onSubmit = jest.fn(); + const {unmount} = renderPage(SCREENS.REPORT, {reportID: REPORT_ID, onSubmit}); + await waitForBatchedUpdatesWithAct(); + + const composer = screen.getByTestId('composer'); + fireEvent.changeText(composer, ''); + fireEvent.changeText(composer, 'https://example.com'); + + await waitFor(() => { + expect(screen.getByTestId('composer').props.value).toBe('https://example.com'); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should insert raw text when non-URL text is pasted', async () => { + const onSubmit = jest.fn(); + const {unmount} = renderPage(SCREENS.REPORT, {reportID: REPORT_ID, onSubmit}); + await waitForBatchedUpdatesWithAct(); + + const composer = screen.getByTestId('composer'); + fireEvent.changeText(composer, 'what you do'); + simulateSelection(composer, 0, 4); + const prevText = 'what you do'; + const pastedText = 'Hello world'; + const merged = prevText.slice(0, 0) + pastedText + prevText.slice(4); + fireEvent.changeText(composer, merged); + + await waitFor(() => { + expect(screen.getByTestId('composer').props.value).toBe('Hello world you do'); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should format pasted URL as Markdown link when replacing entire selected text', async () => { + const onSubmit = jest.fn(); + const {unmount} = renderPage(SCREENS.REPORT, {reportID: REPORT_ID, onSubmit}); + await waitForBatchedUpdatesWithAct(); + + const composer = screen.getByTestId('composer'); + fireEvent.changeText(composer, 'Selected text'); + simulateSelection(composer, 0, 13); + fireEvent.changeText(composer, 'https://example.com'); + + await waitFor(() => { + expect(screen.getByTestId('composer').props.value).toBe('[Selected text](https://example.com)'); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should insert raw URL with emoji when pasted with selection', async () => { + const onSubmit = jest.fn(); + const {unmount} = renderPage(SCREENS.REPORT, {reportID: REPORT_ID, onSubmit}); + await waitForBatchedUpdatesWithAct(); + + const composer = screen.getByTestId('composer'); + const prevText = 'Emoji text 😀'; + fireEvent.changeText(composer, prevText); + simulateSelection(composer, 0, 5); + const pastedText = 'https://example.com 😀'; + const merged = prevText.slice(0, 0) + pastedText + prevText.slice(5); + fireEvent.changeText(composer, merged); + + await waitFor(() => { + expect(screen.getByTestId('composer').props.value).toBe(merged); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should format pasted URL as Markdown link when selected text contains square brackets', async () => { + const onSubmit = jest.fn(); + const {unmount} = renderPage(SCREENS.REPORT, {reportID: REPORT_ID, onSubmit}); + await waitForBatchedUpdatesWithAct(); + + const composer = screen.getByTestId('composer'); + fireEvent.changeText(composer, 'Select]ed[ text'); + simulateSelection(composer, 0, 15); + fireEvent.changeText(composer, 'https://example.com'); + + await waitFor(() => { + expect(screen.getByTestId('composer').props.value).toBe('[Select]ed[ text](https://example.com)'); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should format pasted URL as Markdown link when selected text contains parentheses', async () => { + const onSubmit = jest.fn(); + const {unmount} = renderPage(SCREENS.REPORT, {reportID: REPORT_ID, onSubmit}); + await waitForBatchedUpdatesWithAct(); + + const composer = screen.getByTestId('composer'); + fireEvent.changeText(composer, 'Selected () text'); + simulateSelection(composer, 0, 16); + fireEvent.changeText(composer, 'https://example.com'); + + await waitFor(() => { + expect(screen.getByTestId('composer').props.value).toBe('[Selected () text](https://example.com)'); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should format pasted URL as Markdown link when selected text contains curly braces', async () => { + const onSubmit = jest.fn(); + const {unmount} = renderPage(SCREENS.REPORT, {reportID: REPORT_ID, onSubmit}); + await waitForBatchedUpdatesWithAct(); + + const composer = screen.getByTestId('composer'); + fireEvent.changeText(composer, 'Selec}ted {text'); + simulateSelection(composer, 0, 15); + fireEvent.changeText(composer, 'https://example.com'); + + await waitFor(() => { + expect(screen.getByTestId('composer').props.value).toBe('[Selec}ted {text](https://example.com)'); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + }); +}); diff --git a/tests/ui/hooks/useHtmlPasteTest/index.tsx b/tests/ui/hooks/useHtmlPasteTest/index.tsx new file mode 100644 index 000000000000..365b74f7060d --- /dev/null +++ b/tests/ui/hooks/useHtmlPasteTest/index.tsx @@ -0,0 +1,131 @@ +import {act, renderHook} from '@testing-library/react-native'; +import type {RefObject} from 'react'; +import type {TextInput} from 'react-native'; +import useHtmlPaste from '@hooks/useHtmlPaste'; +import waitForBatchedUpdatesWithAct from '../../../utils/waitForBatchedUpdatesWithAct'; + +type UseHtmlPasteReturn = { + handlePastePlainText?: (event: ClipboardEvent) => void; +}; + +jest.mock('@src/hooks/useHtmlPaste', (): typeof useHtmlPaste => { + return jest.requireActual('@hooks/useHtmlPaste/index.ts'); +}); + +describe('useHtmlPaste - handlePastePlainText', () => { + let textInputRef: RefObject<(HTMLDivElement & Partial) | null>; + + const createMockClipboardEvent = (text: string): ClipboardEvent => { + const clipboardData = { + getData: (type: string) => (type === 'text/plain' ? text : ''), + files: [] as unknown as FileList, + items: [] as unknown as DataTransferItemList, + types: ['text/plain'], + }; + return { + clipboardData, + preventDefault: jest.fn(), + } as unknown as ClipboardEvent; + }; + + const mockWindowSelection = (selectedText: string) => { + const range = document.createRange(); + range.selectNodeContents(textInputRef.current as Node); + range.deleteContents(); + const textNode = document.createTextNode(selectedText); + range.insertNode(textNode); + + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }; + + beforeEach(() => { + jest.clearAllMocks(); + + const div = document.createElement('div'); + div.setAttribute('contenteditable', 'true'); + div.textContent = ''; + document.body.appendChild(div); + textInputRef = {current: div} as RefObject>; + + if (!Range.prototype.getBoundingClientRect) { + Range.prototype.getBoundingClientRect = () => + ({ + top: 0, + left: 0, + width: 0, + height: 0, + right: 0, + bottom: 0, + x: 0, + y: 0, + toJSON: () => {}, + }) as DOMRect; + } + }); + + afterEach(() => { + document.body.removeChild(textInputRef.current as Node); + }); + + it('Paste URL with selection → produces Markdown link', async () => { + const selectedText = 'Expensify'; + const url = 'https://expensify.com'; + const markdownLink = `[${selectedText}](${url})`; + + mockWindowSelection(selectedText); + const event = createMockClipboardEvent(url); + + const {result} = renderHook(() => useHtmlPaste(textInputRef as unknown as RefObject)); + await waitForBatchedUpdatesWithAct(); + + expect(result?.current).toBeDefined(); + + if (result?.current) { + const handlePastePlainText = result?.current.handlePastePlainText; + + act(() => handlePastePlainText?.(event)); + + expect(textInputRef.current?.textContent).toBe(markdownLink); + } + }); + + it('Paste URL without selection → raw URL', async () => { + const url = 'https://example.com'; + mockWindowSelection(''); + const event = createMockClipboardEvent(url); + + const {result} = renderHook(() => useHtmlPaste(textInputRef as unknown as RefObject)); + await waitForBatchedUpdatesWithAct(); + + expect(result?.current).toBeDefined(); + + if (result?.current) { + const handlePastePlainText = result.current.handlePastePlainText; + + act(() => handlePastePlainText?.(event)); + + expect(textInputRef.current?.textContent).toBe(url); + } + }); + + it('Paste non-URL text → raw paste', async () => { + const plainText = 'Hello World'; + mockWindowSelection('what up'); + const event = createMockClipboardEvent(plainText); + + const {result} = renderHook(() => useHtmlPaste(textInputRef as unknown as RefObject)); + await waitForBatchedUpdatesWithAct(); + + expect(result?.current).toBeDefined(); + + if (result?.current) { + const handlePastePlainText = result.current.handlePastePlainText; + + act(() => handlePastePlainText?.(event)); + + expect(textInputRef.current?.textContent).toBe(plainText); + } + }); +}); diff --git a/tests/unit/MarkdownLinkHelpersTest.ts b/tests/unit/MarkdownLinkHelpersTest.ts new file mode 100644 index 000000000000..efb1f625373d --- /dev/null +++ b/tests/unit/MarkdownLinkHelpersTest.ts @@ -0,0 +1,130 @@ +import {detectAndRewritePaste, escapeLinkText, isStandaloneURL, sanitizeUrlForMarkdown, toMarkdownLink} from '@libs/MarkdownLinkHelpers'; + +describe('markdownLinkHelpers', () => { + describe('isStandaloneURL', () => { + it('accepts a valid URL like https://example.com', () => { + expect(isStandaloneURL('https://example.com')).toBe(true); + }); + + it('rejects a URL with whitespace like https://exa mple.com', () => { + expect(isStandaloneURL('https://exa mple.com')).toBe(false); + }); + + it('accepts a valid URL with angle-bracket wrappers like ', () => { + expect(isStandaloneURL('')).toBe(true); + }); + + it('rejects an empty string', () => { + expect(isStandaloneURL('')).toBe(false); + }); + + it('rejects text with multiple words', () => { + expect(isStandaloneURL('hello world')).toBe(false); + }); + + it('rejects invalid URL without protocol', () => { + expect(isStandaloneURL('example.com')).toBe(false); + }); + }); + + describe('escapeLinkText', () => { + it('escapes opening and closing square brackets', () => { + expect(escapeLinkText('a[b]c')).toBe('a[b]c'); + }); + + it('collapses newlines to spaces', () => { + expect(escapeLinkText('line1\nline2\r\nline3')).toBe('line1 line2 line3'); + }); + + it('collapses repeated whitespace to single space and trims', () => { + expect(escapeLinkText(' foo bar ')).toBe('foo bar'); + }); + + it('handles empty string', () => { + expect(escapeLinkText('')).toBe(''); + }); + + it('does not alter text without special characters or whitespace issues', () => { + expect(escapeLinkText('normal text')).toBe('normal text'); + }); + }); + + describe('sanitizeUrlForMarkdown', () => { + it('removes surrounding angle brackets if present', () => { + expect(sanitizeUrlForMarkdown('')).toBe('https://example.com'); + }); + + it('encodes special characters', () => { + expect(sanitizeUrlForMarkdown('https://example.com/path?query=val&other=2')).toBe('https://example.com/path?query=val&other=2'); + }); + + it('handles invalid URLs gracefully without encoding', () => { + expect(sanitizeUrlForMarkdown('invalid url')).toBe('invalid url'); + }); + + it('trims whitespace', () => { + expect(sanitizeUrlForMarkdown(' https://example.com ')).toBe('https://example.com'); + }); + }); + + describe('toMarkdownLink', () => { + it('builds a correct Markdown link with escaped text and sanitized URL', () => { + expect(toMarkdownLink('example text', 'https://example.com')).toBe('[example text](https://example.com)'); + }); + + it('escapes ] in text', () => { + expect(toMarkdownLink('a]b', 'https://example.com')).toBe('[a]b](https://example.com)'); + }); + + it('collapses newlines in selected text', () => { + expect(toMarkdownLink('line1\nline2', 'https://example.com')).toBe('[line1 line2](https://example.com)'); + }); + + it('handles empty text or URL', () => { + expect(toMarkdownLink('', 'https://example.com')).toBe('[](https://example.com)'); + expect(toMarkdownLink('text', '')).toBe('[text]()'); + }); + }); + + describe('detectAndRewritePaste', () => { + it('replaces when there is a selection and inserted text is a single URL', () => { + const prevText = 'This is some selected text here.'; + const selectionStart = 13; + const selectionEnd = 26; + const insertedText = 'https://example.com'; + const result = detectAndRewritePaste(prevText, selectionStart, selectionEnd, insertedText); + expect(result.didReplace).toBe(true); + expect(result.text).toBe('This is some [selected text](https://example.com) here.'); + }); + + it('does not replace when there is no selection', () => { + const prevText = 'Insert here'; + const selectionStart = 7; + const selectionEnd = 7; + const insertedText = 'https://example.com'; + const result = detectAndRewritePaste(prevText, selectionStart, selectionEnd, insertedText); + expect(result.didReplace).toBe(false); + expect(result.text).toBe(null); + }); + + it('does not replace if inserted text is not a standalone URL', () => { + const prevText = 'Selected text'; + const selectionStart = 0; + const selectionEnd = 13; + const insertedText = 'not a url'; + const result = detectAndRewritePaste(prevText, selectionStart, selectionEnd, insertedText); + expect(result.didReplace).toBe(false); + expect(result.text).toBe(null); + }); + + it('handles negative selection length gracefully', () => { + const prevText = 'Text'; + const selectionStart = 5; + const selectionEnd = 0; + const insertedText = 'https://example.com'; + const result = detectAndRewritePaste(prevText, selectionStart, selectionEnd, insertedText); + expect(result.didReplace).toBe(false); + expect(result.text).toBe(null); + }); + }); +});