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,