Skip to content

Commit add16c0

Browse files
committed
feat(rich-markdown-editor): linkify a selection when a URL is pasted over it
Pasting a single URL (or a bare www. host / email) over a non-empty text selection within one block now wraps the selection in a link, keeping the visible text. www. gets https://, an email gets mailto:, and the href is scheme-sanitized (javascript:/data: rejected; mailto: requires a real user@host address). Collapsed carets, cross-block selections, multi-word pastes, node selections, and code contexts fall through to normal paste.
1 parent cc26683 commit add16c0

2 files changed

Lines changed: 94 additions & 1 deletion

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,3 +255,63 @@ describe('markdown paste', () => {
255255
expect(transformHtml(editor, '<script>x<script>y</script>')).toBe('')
256256
})
257257
})
258+
259+
describe('linkify a selection on URL paste', () => {
260+
function linkify(
261+
pasted: string,
262+
from = 1,
263+
to = 10
264+
): { handled: boolean; href?: string; text: string } {
265+
editor = mount()
266+
editor.commands.setContent('select me here', { contentType: 'markdown' })
267+
editor.commands.setTextSelection({ from, to })
268+
const handled = paste(editor, pasted)
269+
return {
270+
handled,
271+
href: JSON.stringify(editor.getJSON()).match(/"href":"([^"]+)"/)?.[1],
272+
text: editor.getText(),
273+
}
274+
}
275+
276+
it('wraps a non-empty text selection in a link when a URL is pasted (keeping the text)', () => {
277+
const r = linkify('https://sim.ai')
278+
expect(r.handled).toBe(true)
279+
expect(r.href).toBe('https://sim.ai')
280+
expect(r.text).toBe('select me here')
281+
})
282+
283+
it('prepends https:// to a bare www host and mailto: to a bare email', () => {
284+
expect(linkify('www.sim.ai').href).toBe('https://www.sim.ai')
285+
expect(linkify('a@b.com').href).toBe('mailto:a@b.com')
286+
})
287+
288+
it('does not linkify a collapsed caret (empty selection)', () => {
289+
const r = linkify('https://sim.ai', 5, 5)
290+
expect(r.handled).toBe(false)
291+
})
292+
293+
it('does not linkify a multi-word paste over a selection', () => {
294+
expect(linkify('not a url just words').handled).toBe(false)
295+
})
296+
297+
it('does not linkify an unsafe javascript: url', () => {
298+
const r = linkify('javascript:alert(1)')
299+
expect(r.handled).toBe(false)
300+
expect(r.href).toBeUndefined()
301+
})
302+
303+
it('links a real mailto: but not a crafted mailto: payload', () => {
304+
expect(linkify('mailto:a@b.com').href).toBe('mailto:a@b.com')
305+
const crafted = linkify('mailto:javascript:alert(1)')
306+
expect(crafted.handled).toBe(false)
307+
expect(crafted.href).toBeUndefined()
308+
})
309+
310+
it('does not linkify a selection spanning multiple blocks', () => {
311+
editor = mount()
312+
editor.commands.setContent('alpha\n\nbeta', { contentType: 'markdown' })
313+
editor.commands.setTextSelection({ from: 3, to: 9 })
314+
expect(paste(editor, 'https://sim.ai')).toBe(false)
315+
expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"link"')
316+
})
317+
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,30 @@
11
import { Extension } from '@tiptap/core'
22
import { Plugin } from '@tiptap/pm/state'
3+
import { normalizeLinkHref } from './markdown-fidelity'
34
import { parseMarkdownToDoc } from './markdown-parse'
45

6+
/**
7+
* A single link the paste can wrap a selection in: an http(s) URL, a `mailto:` to a real address, a bare
8+
* `www.` host, or a bare email. `mailto:` requires an actual `user@host.tld` payload so a crafted value
9+
* like `mailto:javascript:…` (no `@`) never matches and falls through to a normal paste.
10+
*/
11+
const HTTP_URL = /^https?:\/\/\S+$/i
12+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
13+
const MAILTO_URL = /^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/i
14+
const BARE_WWW = /^www\.\S+\.\S+$/i
15+
16+
/**
17+
* If pasted text is a single link, return the href to wrap a selection in — `www.` gets `https://`, a
18+
* bare email gets `mailto:`. Returns null for anything else (a multi-word or non-URL paste falls through
19+
* to normal insertion). The caller still runs the result through `normalizeLinkHref` for scheme safety.
20+
*/
21+
function pastedLinkHref(text: string): string | null {
22+
if (HTTP_URL.test(text) || MAILTO_URL.test(text)) return text
23+
if (BARE_WWW.test(text)) return `https://${text}`
24+
if (EMAIL.test(text)) return `mailto:${text}`
25+
return null
26+
}
27+
528
/**
629
* Structural markdown — strong signals the plain text is genuinely markdown (a link, image, badge,
730
* list, heading, blockquote, fenced block, or GFM table). Our parser round-trips these more faithfully
@@ -143,11 +166,21 @@ export const MarkdownPaste = Extension.create({
143166
new Plugin({
144167
props: {
145168
transformPastedHTML: (html) => stripNonContentHtml(html),
146-
handlePaste: (_view, event) => {
169+
handlePaste: (view, event) => {
147170
if (!editor.isEditable) return false
148171
if (editor.isActive('codeBlock') || editor.isActive('code')) return false
149172
const text = event.clipboardData?.getData('text/plain')
150173
if (!text) return false
174+
const { selection } = view.state
175+
if (
176+
!selection.empty &&
177+
selection.$from.sameParent(selection.$to) &&
178+
selection.$from.parent.inlineContent
179+
) {
180+
const href = pastedLinkHref(text.trim())
181+
const safeHref = href ? normalizeLinkHref(href) : ''
182+
if (safeHref) return editor.commands.setLink({ href: safeHref })
183+
}
151184
const language = parseVscodeLanguage(event.clipboardData?.getData('vscode-editor-data'))
152185
if (language) {
153186
return editor.commands.insertContent({

0 commit comments

Comments
 (0)