diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts
index 789c2d8a60c..b36857c037e 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts
@@ -94,6 +94,14 @@ describe('markdown paste', () => {
expect(paste(editor, '[link](https://example.com)')).toBe(false)
})
+ it('keeps pasted markdown literal inside inline code', () => {
+ editor = mount()
+ editor.commands.setContent('a `codehere` b', { contentType: 'markdown' })
+ editor.commands.setTextSelection(6)
+ expect(editor.isActive('code')).toBe(true)
+ expect(paste(editor, '*italic*')).toBe(false)
+ })
+
it('rejects the paste entirely in a read-only editor', () => {
editor = mount(false)
expect(paste(editor, '# heading\n\n- one\n- two')).toBe(false)
@@ -104,21 +112,19 @@ describe('markdown paste', () => {
['empty string', ''],
['whitespace only', ' \n\n '],
['a bare thematic break (ambiguous — needs another markdown signal)', '---'],
- ['inline-only italic (single asterisk would false-positive on e.g. *args)', 'an *italic* word'],
- ['inline-only strikethrough', 'a ~~struck~~ word'],
- ['inline-only code', 'some `code` here'],
])('leaves %s to the default handler', (_label, text) => {
editor = mount()
expect(paste(editor, text)).toBe(false)
})
- // Only structural / unambiguous constructs gate the markdown parse. Inline-only marks that
- // `looksLikeMarkdown` deliberately omits to avoid false positives — single-asterisk italic
- // (`*args`), `~~`, single-backtick code — are covered by the Markdown extension's own paste path,
- // not MarkdownPaste, so they belong to a different test surface.
it.each([
['heading', '# Heading', 'heading'],
['bold', 'a **bold** word', 'bold'],
+ ['italic', 'an *italic* word', 'italic'],
+ ['underscore italic', 'an _italic_ word', 'italic'],
+ ['underscore bold', 'a __bold__ word', 'bold'],
+ ['strikethrough', 'a ~~struck~~ word', 'strike'],
+ ['inline code', 'some `code` here', 'code'],
['bullet list', '- one\n- two', 'bulletList'],
['ordered list', '1. one\n2. two', 'orderedList'],
['task list', '- [x] done\n- [ ] todo', 'taskList'],
@@ -132,6 +138,28 @@ describe('markdown paste', () => {
expect(JSON.stringify(editor.getJSON())).toContain(`"type":"${nodeType}"`)
})
+ it.each([
+ ['italic', 'an *italic* word', '
an italic word
'],
+ ['strikethrough', 'a ~~struck~~ word', 'a struck word
'],
+ ['inline code', 'some `code` here', 'some code here
'],
+ ])('defers inline-only %s to a rich HTML sibling (keeps its structure)', (_label, text, html) => {
+ editor = mount()
+ expect(paste(editor, text, html)).toBe(false)
+ })
+
+ it.each([
+ ['space-flanked asterisks', 'area = 5 * width * height'],
+ ['python args and kwargs', 'def foo(*args, **kwargs): pass'],
+ ['snake_case identifiers', 'call user_name and file_path_here'],
+ ])('claims %s but leaves it byte-for-byte literal (strict CommonMark)', (_label, text) => {
+ editor = mount()
+ expect(paste(editor, text)).toBe(true)
+ const json = JSON.stringify(editor.getJSON())
+ expect(json).not.toContain('"type":"italic"')
+ expect(json).not.toContain('"type":"bold"')
+ expect(editor.getText()).toBe(text)
+ })
+
it('parses markdown-shaped plain text even when an HTML sibling is present', () => {
editor = mount()
const html = 'Title
'
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts
index 48bca04aceb..88a9debbd9a 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts
@@ -3,11 +3,12 @@ import { Plugin } from '@tiptap/pm/state'
import { parseMarkdownToDoc } from './markdown-parse'
/**
- * Markdown syntax hints. If pasted plain text matches any of these, it's parsed as markdown rather
- * than inserted literally — so a pasted link, image, badge, list, or heading renders as rich content
- * instead of showing its raw `[text](url)` / `# ` source.
+ * Structural markdown — strong signals the plain text is genuinely markdown (a link, image, badge,
+ * list, heading, blockquote, fenced block, or GFM table). Our parser round-trips these more faithfully
+ * than generic HTML→DOM mapping (GFM alignment, escaping, the `./raw-markdown-snippet.ts` constructs),
+ * so they are parsed even when the clipboard also carries an HTML sibling.
*/
-const MARKDOWN_HINTS: ReadonlyArray = [
+const STRUCTURAL_MARKDOWN_HINTS: ReadonlyArray = [
/^#{1,6}\s/m,
/\*\*[^*]+\*\*/,
/\[[^\]]*]\([^)]+\)/,
@@ -18,21 +19,40 @@ const MARKDOWN_HINTS: ReadonlyArray = [
/^\|.*\|.*\|/m,
]
-function looksLikeMarkdown(text: string): boolean {
- return MARKDOWN_HINTS.some((hint) => hint.test(text))
+/**
+ * Inline marks — weaker markdown signals (`*italic*` / `_italic_`, `~~strike~~`, `` `code` ``) that a
+ * rich HTML sibling encodes just as well. Parsed for a plain-text-only paste (so markdown copied from a
+ * terminal or `.md` source renders), but deferred to an HTML sibling: its presence means the source was
+ * rich, and it may carry structure the plain text can't (a copied table's plain form is tab-separated,
+ * not a `| … |` grid, so parsing it would flatten the table).
+ */
+const INLINE_MARK_HINTS: ReadonlyArray = [
+ /\*[^*\n]+\*/,
+ /_[^_\n]+_/,
+ /~~[^~\n]+~~/,
+ /`[^`\n]+`/,
+]
+
+function hasAny(hints: ReadonlyArray, text: string): boolean {
+ return hints.some((hint) => hint.test(text))
}
/**
- * Parses pasted plain text that looks like markdown into rich content. Pastes inside a code block
- * are left untouched (code is meant to stay literal).
+ * Parses pasted plain text that looks like markdown into rich content, via the strict CommonMark
+ * parser ({@link parseMarkdownToDoc}, `marked`). Pastes inside a code block or inline code are left
+ * untouched (code is meant to stay literal).
+ *
+ * Provenance decides plain-text-vs-HTML: a `text/html` sibling (copied from a browser, Slack, Notion,
+ * GitHub, or this editor) is the signal the source was rich. Structural markdown is still parsed from
+ * the plain-text sibling regardless — our parser is more faithful for GFM tables and escaping. But
+ * inline-only marks are equally expressible in HTML, so when a rich sibling is present we defer to the
+ * DOM path, which preserves structure the plain text can't encode. A plain-text-only clipboard (a
+ * terminal, a code editor, a `.md` file) always parses.
*
- * A clipboard entry that also carries `text/html` (copied from a browser, Slack, Notion, GitHub,
- * or this editor itself) used to always defer entirely to ProseMirror's generic HTML→DOM mapping,
- * even when the `text/plain` sibling was clean markdown our own parser round-trips more faithfully
- * (GFM table alignment, escaping, the constructs `./raw-markdown-snippet.ts` now preserves). Only
- * defer to DOM mapping when the plain-text sibling *doesn't* look like markdown — an HTML clipboard
- * payload with no markdown-shaped plain-text counterpart (a genuinely rich paste from a word
- * processor, a web page selection, …) still goes through the DOM path unchanged.
+ * The strictness of the parse matters: `marked` follows CommonMark flanking rules, so `*text*` becomes
+ * emphasis but a space-flanked `5 * width * height` stays literal. The editor sets `enablePasteRules:
+ * false` so StarterKit's lenient mark paste rules (which would mangle that expression on either path)
+ * never run — emphasis is owned by this parser on the plain path and by real HTML tags on the DOM path.
*/
export const MarkdownPaste = Extension.create({
name: 'markdownPaste',
@@ -44,11 +64,13 @@ export const MarkdownPaste = Extension.create({
props: {
handlePaste: (_view, event) => {
if (!editor.isEditable) return false
- if (editor.isActive('codeBlock')) return false
+ if (editor.isActive('codeBlock') || editor.isActive('code')) return false
const text = event.clipboardData?.getData('text/plain')
- if (!text || !looksLikeMarkdown(text)) return false
- // Parse through the chunker (linear) so pasting a large markdown blob can't freeze the
- // main thread the way the underlying superlinear parse would.
+ if (!text) return false
+ if (!hasAny(STRUCTURAL_MARKDOWN_HINTS, text)) {
+ if (!hasAny(INLINE_MARK_HINTS, text)) return false
+ if (event.clipboardData?.getData('text/html')) return false
+ }
const doc = parseMarkdownToDoc(text)
if (!doc.content?.length) return false
return editor.commands.insertContent(doc)
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
index b141d8bab64..d6fc15224a1 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
@@ -250,6 +250,7 @@ export function LoadedRichMarkdownEditor({
const editor = useEditor({
extensions: EXTENSIONS,
editable: isEditable,
+ enablePasteRules: false,
autofocus: streamingAtMountRef.current ? false : autoFocus ? 'end' : false,
immediatelyRender: false,
shouldRerenderOnTransaction: false,
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx
index 202ed9b4651..8936c9a489b 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx
@@ -97,6 +97,7 @@ function LoadedRichMarkdownField({
const editor = useEditor({
extensions,
editable: !disabled && !isStreaming,
+ enablePasteRules: false,
autofocus: autoFocus ? 'end' : false,
immediatelyRender: false,
shouldRerenderOnTransaction: false,