From c92eb17f70c175875c237467105bd1d5d2c96e71 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Jul 2026 19:19:47 -0700 Subject: [PATCH 1/3] fix(rich-markdown-editor): strict, provenance-aware markdown paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pasting code-like text such as `5 * width * height` was italicized by StarterKit's lenient mark paste rules. Own emphasis with the strict CommonMark parser instead: - Disable StarterKit's mark paste rules (`enablePasteRules: false`); markdown paste is handled by MarkdownPaste (marked) and rich paste by real HTML tags. Input rules (typing) are unaffected. - Split the paste gate by provenance: structural markdown always parses (faithful GFM tables and escaping), while inline-only marks parse for a plain-text paste but defer to a rich HTML sibling so a copied table isn't flattened. Emphasis (`*_ ** __ ~~ ``) renders; `5 * width`, `*args`, and `snake_case` stay literal — matching Obsidian/Linear. --- .../markdown-paste.test.ts | 33 ++++++++--- .../rich-markdown-editor/markdown-paste.ts | 58 +++++++++++++------ .../rich-markdown-editor.tsx | 1 + .../rich-markdown-field.tsx | 1 + 4 files changed, 68 insertions(+), 25 deletions(-) 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..9110425730c 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 @@ -104,21 +104,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 +130,27 @@ 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'], + ])('does not emphasize %s (strict CommonMark)', (_label, text) => { + editor = mount() + paste(editor, text) + const json = JSON.stringify(editor.getJSON()) + expect(json).not.toContain('"type":"italic"') + expect(json).not.toContain('"type":"bold"') + }) + 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..8b139feb687 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 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', @@ -46,9 +66,11 @@ export const MarkdownPaste = Extension.create({ if (!editor.isEditable) return false if (editor.isActive('codeBlock')) 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, From 4bf7547deb1cc2387f2ad22e8e5ad008229b95d6 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Jul 2026 19:32:09 -0700 Subject: [PATCH 2/3] test(rich-markdown-editor): assert code-like pastes are claimed but preserved byte-for-byte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate is intentionally lenient — a precise CommonMark-emphasis matcher would risk missing real emphasis. Over-claiming is safe because the strict parser (marked) preserves non-markdown exactly; assert the handler claims the paste and returns the input unchanged, not just that no mark is added. --- .../file-viewer/rich-markdown-editor/markdown-paste.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 9110425730c..97d01acd507 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 @@ -143,12 +143,13 @@ describe('markdown paste', () => { ['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'], - ])('does not emphasize %s (strict CommonMark)', (_label, text) => { + ])('claims %s but leaves it byte-for-byte literal (strict CommonMark)', (_label, text) => { editor = mount() - paste(editor, text) + 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', () => { From aefb4449b6710dd18a1e7528c1d0908dfe3086d7 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Jul 2026 19:49:01 -0700 Subject: [PATCH 3/3] fix(rich-markdown-editor): keep paste literal inside inline code too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paste handler skipped a fenced code block but not the inline code mark. Now that inline marks gate the parse, pasting `*italic*` inside inline code would render rich instead of staying literal — extend the code-context guard to editor.isActive('code'). --- .../rich-markdown-editor/markdown-paste.test.ts | 8 ++++++++ .../file-viewer/rich-markdown-editor/markdown-paste.ts | 6 +++--- 2 files changed, 11 insertions(+), 3 deletions(-) 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 97d01acd507..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) 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 8b139feb687..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 @@ -39,8 +39,8 @@ function hasAny(hints: ReadonlyArray, text: string): boolean { /** * Parses pasted plain text that looks like markdown into rich content, via the strict CommonMark - * parser ({@link parseMarkdownToDoc}, `marked`). Pastes inside a code block are left untouched (code - * is meant to stay literal). + * 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 @@ -64,7 +64,7 @@ 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) return false if (!hasAny(STRUCTURAL_MARKDOWN_HINTS, text)) {