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