Skip to content

Commit c92eb17

Browse files
committed
fix(rich-markdown-editor): strict, provenance-aware markdown paste
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.
1 parent 11be2a3 commit c92eb17

4 files changed

Lines changed: 68 additions & 25 deletions

File tree

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

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,21 +104,19 @@ describe('markdown paste', () => {
104104
['empty string', ''],
105105
['whitespace only', ' \n\n '],
106106
['a bare thematic break (ambiguous — needs another markdown signal)', '---'],
107-
['inline-only italic (single asterisk would false-positive on e.g. *args)', 'an *italic* word'],
108-
['inline-only strikethrough', 'a ~~struck~~ word'],
109-
['inline-only code', 'some `code` here'],
110107
])('leaves %s to the default handler', (_label, text) => {
111108
editor = mount()
112109
expect(paste(editor, text)).toBe(false)
113110
})
114111

115-
// Only structural / unambiguous constructs gate the markdown parse. Inline-only marks that
116-
// `looksLikeMarkdown` deliberately omits to avoid false positives — single-asterisk italic
117-
// (`*args`), `~~`, single-backtick code — are covered by the Markdown extension's own paste path,
118-
// not MarkdownPaste, so they belong to a different test surface.
119112
it.each([
120113
['heading', '# Heading', 'heading'],
121114
['bold', 'a **bold** word', 'bold'],
115+
['italic', 'an *italic* word', 'italic'],
116+
['underscore italic', 'an _italic_ word', 'italic'],
117+
['underscore bold', 'a __bold__ word', 'bold'],
118+
['strikethrough', 'a ~~struck~~ word', 'strike'],
119+
['inline code', 'some `code` here', 'code'],
122120
['bullet list', '- one\n- two', 'bulletList'],
123121
['ordered list', '1. one\n2. two', 'orderedList'],
124122
['task list', '- [x] done\n- [ ] todo', 'taskList'],
@@ -132,6 +130,27 @@ describe('markdown paste', () => {
132130
expect(JSON.stringify(editor.getJSON())).toContain(`"type":"${nodeType}"`)
133131
})
134132

133+
it.each([
134+
['italic', 'an *italic* word', '<p>an <em>italic</em> word</p>'],
135+
['strikethrough', 'a ~~struck~~ word', '<p>a <del>struck</del> word</p>'],
136+
['inline code', 'some `code` here', '<p>some <code>code</code> here</p>'],
137+
])('defers inline-only %s to a rich HTML sibling (keeps its structure)', (_label, text, html) => {
138+
editor = mount()
139+
expect(paste(editor, text, html)).toBe(false)
140+
})
141+
142+
it.each([
143+
['space-flanked asterisks', 'area = 5 * width * height'],
144+
['python args and kwargs', 'def foo(*args, **kwargs): pass'],
145+
['snake_case identifiers', 'call user_name and file_path_here'],
146+
])('does not emphasize %s (strict CommonMark)', (_label, text) => {
147+
editor = mount()
148+
paste(editor, text)
149+
const json = JSON.stringify(editor.getJSON())
150+
expect(json).not.toContain('"type":"italic"')
151+
expect(json).not.toContain('"type":"bold"')
152+
})
153+
135154
it('parses markdown-shaped plain text even when an HTML sibling is present', () => {
136155
editor = mount()
137156
const html = '<h1>Title</h1><ul><li>a</li><li>b</li></ul>'

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

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import { Plugin } from '@tiptap/pm/state'
33
import { parseMarkdownToDoc } from './markdown-parse'
44

55
/**
6-
* Markdown syntax hints. If pasted plain text matches any of these, it's parsed as markdown rather
7-
* than inserted literally — so a pasted link, image, badge, list, or heading renders as rich content
8-
* instead of showing its raw `[text](url)` / `# ` source.
6+
* Structural markdown — strong signals the plain text is genuinely markdown (a link, image, badge,
7+
* list, heading, blockquote, fenced block, or GFM table). Our parser round-trips these more faithfully
8+
* than generic HTML→DOM mapping (GFM alignment, escaping, the `./raw-markdown-snippet.ts` constructs),
9+
* so they are parsed even when the clipboard also carries an HTML sibling.
910
*/
10-
const MARKDOWN_HINTS: ReadonlyArray<RegExp> = [
11+
const STRUCTURAL_MARKDOWN_HINTS: ReadonlyArray<RegExp> = [
1112
/^#{1,6}\s/m,
1213
/\*\*[^*]+\*\*/,
1314
/\[[^\]]*]\([^)]+\)/,
@@ -18,21 +19,40 @@ const MARKDOWN_HINTS: ReadonlyArray<RegExp> = [
1819
/^\|.*\|.*\|/m,
1920
]
2021

21-
function looksLikeMarkdown(text: string): boolean {
22-
return MARKDOWN_HINTS.some((hint) => hint.test(text))
22+
/**
23+
* Inline marks — weaker markdown signals (`*italic*` / `_italic_`, `~~strike~~`, `` `code` ``) that a
24+
* rich HTML sibling encodes just as well. Parsed for a plain-text-only paste (so markdown copied from a
25+
* terminal or `.md` source renders), but deferred to an HTML sibling: its presence means the source was
26+
* rich, and it may carry structure the plain text can't (a copied table's plain form is tab-separated,
27+
* not a `| … |` grid, so parsing it would flatten the table).
28+
*/
29+
const INLINE_MARK_HINTS: ReadonlyArray<RegExp> = [
30+
/\*[^*\n]+\*/,
31+
/_[^_\n]+_/,
32+
/~~[^~\n]+~~/,
33+
/`[^`\n]+`/,
34+
]
35+
36+
function hasAny(hints: ReadonlyArray<RegExp>, text: string): boolean {
37+
return hints.some((hint) => hint.test(text))
2338
}
2439

2540
/**
26-
* Parses pasted plain text that looks like markdown into rich content. Pastes inside a code block
27-
* are left untouched (code is meant to stay literal).
41+
* Parses pasted plain text that looks like markdown into rich content, via the strict CommonMark
42+
* parser ({@link parseMarkdownToDoc}, `marked`). Pastes inside a code block are left untouched (code
43+
* is meant to stay literal).
44+
*
45+
* Provenance decides plain-text-vs-HTML: a `text/html` sibling (copied from a browser, Slack, Notion,
46+
* GitHub, or this editor) is the signal the source was rich. Structural markdown is still parsed from
47+
* the plain-text sibling regardless — our parser is more faithful for GFM tables and escaping. But
48+
* inline-only marks are equally expressible in HTML, so when a rich sibling is present we defer to the
49+
* DOM path, which preserves structure the plain text can't encode. A plain-text-only clipboard (a
50+
* terminal, a code editor, a `.md` file) always parses.
2851
*
29-
* A clipboard entry that also carries `text/html` (copied from a browser, Slack, Notion, GitHub,
30-
* or this editor itself) used to always defer entirely to ProseMirror's generic HTML→DOM mapping,
31-
* even when the `text/plain` sibling was clean markdown our own parser round-trips more faithfully
32-
* (GFM table alignment, escaping, the constructs `./raw-markdown-snippet.ts` now preserves). Only
33-
* defer to DOM mapping when the plain-text sibling *doesn't* look like markdown — an HTML clipboard
34-
* payload with no markdown-shaped plain-text counterpart (a genuinely rich paste from a word
35-
* processor, a web page selection, …) still goes through the DOM path unchanged.
52+
* The strictness of the parse matters: `marked` follows CommonMark flanking rules, so `*text*` becomes
53+
* emphasis but a space-flanked `5 * width * height` stays literal. The editor sets `enablePasteRules:
54+
* false` so StarterKit's lenient mark paste rules (which would mangle that expression on either path)
55+
* never run — emphasis is owned by this parser on the plain path and by real HTML tags on the DOM path.
3656
*/
3757
export const MarkdownPaste = Extension.create({
3858
name: 'markdownPaste',
@@ -46,9 +66,11 @@ export const MarkdownPaste = Extension.create({
4666
if (!editor.isEditable) return false
4767
if (editor.isActive('codeBlock')) return false
4868
const text = event.clipboardData?.getData('text/plain')
49-
if (!text || !looksLikeMarkdown(text)) return false
50-
// Parse through the chunker (linear) so pasting a large markdown blob can't freeze the
51-
// main thread the way the underlying superlinear parse would.
69+
if (!text) return false
70+
if (!hasAny(STRUCTURAL_MARKDOWN_HINTS, text)) {
71+
if (!hasAny(INLINE_MARK_HINTS, text)) return false
72+
if (event.clipboardData?.getData('text/html')) return false
73+
}
5274
const doc = parseMarkdownToDoc(text)
5375
if (!doc.content?.length) return false
5476
return editor.commands.insertContent(doc)

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ export function LoadedRichMarkdownEditor({
250250
const editor = useEditor({
251251
extensions: EXTENSIONS,
252252
editable: isEditable,
253+
enablePasteRules: false,
253254
autofocus: streamingAtMountRef.current ? false : autoFocus ? 'end' : false,
254255
immediatelyRender: false,
255256
shouldRerenderOnTransaction: false,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ function LoadedRichMarkdownField({
9797
const editor = useEditor({
9898
extensions,
9999
editable: !disabled && !isStreaming,
100+
enablePasteRules: false,
100101
autofocus: autoFocus ? 'end' : false,
101102
immediatelyRender: false,
102103
shouldRerenderOnTransaction: false,

0 commit comments

Comments
 (0)