diff --git a/.changeset/weak-peaches-teach.md b/.changeset/weak-peaches-teach.md new file mode 100644 index 00000000000..6720e40392c --- /dev/null +++ b/.changeset/weak-peaches-teach.md @@ -0,0 +1,5 @@ +--- +"@primer/react": minor +--- + +Add `pasteUrlsAsPlainText` prop to control URL pasting behavior in `MarkdownEditor` diff --git a/package-lock.json b/package-lock.json index e6b38bee612..04ca1283902 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "@primer/react", - "version": "35.9.0", + "version": "35.10.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@primer/react", - "version": "35.9.0", + "version": "35.10.0", "license": "MIT", "dependencies": { "@github/combobox-nav": "^2.1.5", "@github/markdown-toolbar-element": "^2.1.0", - "@github/paste-markdown": "^1.3.1", + "@github/paste-markdown": "^1.4.0", "@primer/behaviors": "^1.1.1", "@primer/octicons-react": "^17.3.0", "@primer/primitives": "7.9.0", @@ -3148,9 +3148,9 @@ "integrity": "sha512-nYjuErjGl5enF6BiZsP9qL7fbH5YRIOebv0vFru7MJu6Yu/ayQjb0iRrm8zLKq/IELMUYQHw9+Uifl3qUyTKrA==" }, "node_modules/@github/paste-markdown": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@github/paste-markdown/-/paste-markdown-1.3.1.tgz", - "integrity": "sha512-xew5uSUOPm+pD4dSvR0/qtq2USUNYQ6ehpOqxK550+UAO4FWSEnNHjR0PA2Tul9PGyGBKfB+LQ5yF9DZwbpwUw==" + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@github/paste-markdown/-/paste-markdown-1.4.0.tgz", + "integrity": "sha512-BU//VcOhAFIz/t36NW/nlmGIFqTrPVMECZEZx+jlrEh87qCoO79fLIcsLr2xAT7E/F/lLAuYFVSwpnsPCAPdrQ==" }, "node_modules/@github/prettier-config": { "version": "0.0.4", @@ -43269,9 +43269,9 @@ "integrity": "sha512-nYjuErjGl5enF6BiZsP9qL7fbH5YRIOebv0vFru7MJu6Yu/ayQjb0iRrm8zLKq/IELMUYQHw9+Uifl3qUyTKrA==" }, "@github/paste-markdown": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@github/paste-markdown/-/paste-markdown-1.3.1.tgz", - "integrity": "sha512-xew5uSUOPm+pD4dSvR0/qtq2USUNYQ6ehpOqxK550+UAO4FWSEnNHjR0PA2Tul9PGyGBKfB+LQ5yF9DZwbpwUw==" + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@github/paste-markdown/-/paste-markdown-1.4.0.tgz", + "integrity": "sha512-BU//VcOhAFIz/t36NW/nlmGIFqTrPVMECZEZx+jlrEh87qCoO79fLIcsLr2xAT7E/F/lLAuYFVSwpnsPCAPdrQ==" }, "@github/prettier-config": { "version": "0.0.4", diff --git a/package.json b/package.json index 49f4fa3de59..b506b9f01f9 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "dependencies": { "@github/combobox-nav": "^2.1.5", "@github/markdown-toolbar-element": "^2.1.0", - "@github/paste-markdown": "^1.3.1", + "@github/paste-markdown": "^1.4.0", "@primer/behaviors": "^1.1.1", "@primer/octicons-react": "^17.3.0", "@primer/primitives": "7.9.0", diff --git a/src/drafts/MarkdownEditor/MarkdownEditor.stories.tsx b/src/drafts/MarkdownEditor/MarkdownEditor.stories.tsx index b551d938131..79d6dfa00bd 100644 --- a/src/drafts/MarkdownEditor/MarkdownEditor.stories.tsx +++ b/src/drafts/MarkdownEditor/MarkdownEditor.stories.tsx @@ -30,7 +30,8 @@ const meta: Meta = { 'Hide Label', 'Required', 'Enable File Uploads', - 'Enable Saved Replies' + 'Enable Saved Replies', + 'Enable Plain-Text URL Pasting' ] } }, @@ -57,6 +58,13 @@ const meta: Meta = { type: 'boolean' } }, + pasteUrlsAsPlainText: { + name: 'Enable Plain-Text URL Pasting', + defaultValue: false, + control: { + type: 'boolean' + } + }, minHeightLines: { name: 'Minimum Height (Lines)', defaultValue: 5, @@ -122,6 +130,7 @@ type ArgProps = { required: boolean fileUploadsEnabled: boolean savedRepliesEnabled: boolean + pasteUrlsAsPlainText: boolean onSubmit: () => void onDiffClick: () => void } @@ -200,7 +209,8 @@ export const Default = ({ required, fileUploadsEnabled, onSubmit, - savedRepliesEnabled + savedRepliesEnabled, + pasteUrlsAsPlainText }: ArgProps) => { const [value, setValue] = useState('') @@ -223,6 +233,7 @@ export const Default = ({ referenceSuggestions={references} savedReplies={savedRepliesEnabled ? savedReplies : undefined} required={required} + pasteUrlsAsPlainText={pasteUrlsAsPlainText} > Markdown Editor Example @@ -242,7 +253,8 @@ export const CustomButtons = ({ fileUploadsEnabled, onSubmit, onDiffClick, - savedRepliesEnabled + savedRepliesEnabled, + pasteUrlsAsPlainText }: ArgProps) => { const [value, setValue] = useState('') @@ -265,6 +277,7 @@ export const CustomButtons = ({ referenceSuggestions={references} required={required} savedReplies={savedRepliesEnabled ? savedReplies : undefined} + pasteUrlsAsPlainText={pasteUrlsAsPlainText} > Markdown Editor Example diff --git a/src/drafts/MarkdownEditor/MarkdownEditor.test.tsx b/src/drafts/MarkdownEditor/MarkdownEditor.test.tsx index a3f3d7ad16a..f730c667fee 100644 --- a/src/drafts/MarkdownEditor/MarkdownEditor.test.tsx +++ b/src/drafts/MarkdownEditor/MarkdownEditor.test.tsx @@ -1,6 +1,7 @@ import {DiffAddedIcon} from '@primer/octicons-react' import {fireEvent, render as _render, waitFor, within} from '@testing-library/react' import userEvent from '@testing-library/user-event' +import {UserEvent} from '@testing-library/user-event/dist/types/setup' import React, {forwardRef, useLayoutEffect, useRef, useState} from 'react' import MarkdownEditor, {Emoji, MarkdownEditorHandle, MarkdownEditorProps, Mentionable, Reference, SavedReply} from '.' import ThemeProvider from '../../ThemeProvider' @@ -1140,4 +1141,34 @@ describe('MarkdownEditor', () => { ) } }) + + describe('pasting URLs', () => { + const typeAndPaste = async (user: UserEvent, input: HTMLTextAreaElement) => { + await user.type(input, 'lorem ipsum dolor sit amet') + input.setSelectionRange(6, 11) + + // userEvent.paste() doesn't seem to fire the `paste` event that paste-markdown listens for. + // So we simulate it. This approach is somewhat fragile because it relies on the internals + // of paste-markdown not using any other properties on the event or DataTransfer instance. + // We can't just construct a `new DataTransfer` because that's not implemented in JSDOM. + fireEvent.paste(input, {clipboardData: {types: ['text/plain'], getData: () => 'https://github.com'}}) + } + + const linkifiedResult = 'lorem [ipsum](https://github.com) dolor sit amet' + const plainResult = 'lorem ipsum dolor sit amet' // the real-world plain text result should have "https://github.com" instead of "ipsum", but fireEvent.paste doesn't actually update the input value + + it('pastes URLs onto selected text as links by default', async () => { + const {getInput, user} = await render() + const input = getInput() + await typeAndPaste(user, input) + expect(input).toHaveValue(linkifiedResult) + }) + + it('pastes URLs onto selected text as plain text when `pasteUrlsAsPlainText` enabled', async () => { + const {getInput, user} = await render() + const input = getInput() + await typeAndPaste(user, input) + expect(input).toHaveValue(plainResult) + }) + }) }) diff --git a/src/drafts/MarkdownEditor/MarkdownEditor.tsx b/src/drafts/MarkdownEditor/MarkdownEditor.tsx index 061d083d231..5627bb34c4d 100644 --- a/src/drafts/MarkdownEditor/MarkdownEditor.tsx +++ b/src/drafts/MarkdownEditor/MarkdownEditor.tsx @@ -95,6 +95,14 @@ export type MarkdownEditorProps = SxProp & { name?: string /** To enable the saved replies feature, provide an array of replies. */ savedReplies?: SavedReply[] + /** + * Control whether URLs are pasted as plain text instead of as formatted links (if the + * user has selected some text before pasting). Defaults to `false` (URLs will paste as + * links). This should typically be controlled by user settings. + * + * Users can always toggle this behavior by holding `shift` when pasting. + */ + pasteUrlsAsPlainText?: boolean } const handleBrand = Symbol() @@ -157,7 +165,8 @@ const MarkdownEditor = forwardRef( required = false, name, children, - savedReplies + savedReplies, + pasteUrlsAsPlainText = false }, ref ) => { @@ -389,6 +398,7 @@ const MarkdownEditor = forwardRef( monospace={monospace} required={required} name={name} + pasteUrlsAsPlainText={pasteUrlsAsPlainText} {...inputCompositionProps} {...fileHandler?.pasteTargetProps} {...fileHandler?.dropTargetProps} diff --git a/src/drafts/MarkdownEditor/_MarkdownInput.tsx b/src/drafts/MarkdownEditor/_MarkdownInput.tsx index 98770fbf3d0..eef06b1d0eb 100644 --- a/src/drafts/MarkdownEditor/_MarkdownInput.tsx +++ b/src/drafts/MarkdownEditor/_MarkdownInput.tsx @@ -24,6 +24,7 @@ interface MarkdownInputProps extends Omit { minHeightLines: number maxHeightLines: number monospace: boolean + pasteUrlsAsPlainText: boolean /** Use this prop to control visibility instead of unmounting, so the undo stack and custom height are preserved. */ visible: boolean } @@ -47,6 +48,7 @@ export const MarkdownInput = forwardRef maxHeightLines, visible, monospace, + pasteUrlsAsPlainText, ...props }, forwardedRef @@ -81,7 +83,12 @@ export const MarkdownInput = forwardRef const ref = useRef(null) useRefObjectAsForwardedRef(forwardedRef, ref) - useEffect(() => (ref.current ? subscribeToMarkdownPasting(ref.current).unsubscribe : undefined), []) + useEffect(() => { + const subscription = + ref.current && + subscribeToMarkdownPasting(ref.current, {defaultPlainTextPaste: {urlLinks: pasteUrlsAsPlainText}}) + return subscription?.unsubscribe + }, [pasteUrlsAsPlainText]) const dynamicHeightStyles = useDynamicTextareaHeight({maxHeightLines, minHeightLines, element: ref.current, value}) const heightStyles = fullHeight ? {} : dynamicHeightStyles