Skip to content

Commit 04a375f

Browse files
committed
feat(rich-markdown-editor): VSCode code paste and strip <style>/<script> from pasted HTML
- Code copied from VSCode carries a `vscode-editor-data` payload with the source language, but its text/html is per-token colored spans that ProseMirror flattens into plain paragraphs. Read the payload and paste a real fenced code block with the mapped language. markdown/plaintext modes resolve to no language and fall through, so markdown copied from VSCode still parses as rich content rather than a fenced block. - Google Sheets and Word prepend a `<style>` block of CSS that PM's DOM parser walks into the document as literal text. Strip <style>/<script> in transformPastedHTML before parsing. - Add `==…==` to the inline-mark hints so a plain-text paste of `==highlight==` routes through the markdown parser and becomes a highlight mark (paste rules are disabled on this editor).
1 parent c900c95 commit 04a375f

2 files changed

Lines changed: 121 additions & 2 deletions

File tree

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

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ function mount(editable = true): Editor {
2121
}
2222

2323
/** Run the plugin paste handlers the way ProseMirror would, with a mocked clipboard. */
24-
function paste(ed: Editor, text: string, html = ''): boolean {
24+
function paste(ed: Editor, text: string, html = '', extra: Record<string, string> = {}): boolean {
2525
const event = {
2626
clipboardData: {
27-
getData: (type: string) => (type === 'text/plain' ? text : type === 'text/html' ? html : ''),
27+
getData: (type: string) =>
28+
type === 'text/plain' ? text : type === 'text/html' ? html : (extra[type] ?? ''),
2829
},
2930
} as unknown as ClipboardEvent
3031
for (const plugin of ed.view.state.plugins) {
@@ -35,6 +36,16 @@ function paste(ed: Editor, text: string, html = ''): boolean {
3536
return false
3637
}
3738

39+
/** Run the plugin `transformPastedHTML` chain the way ProseMirror would. */
40+
function transformHtml(ed: Editor, html: string): string {
41+
let out = html
42+
for (const plugin of ed.view.state.plugins) {
43+
const fn = plugin.props?.transformPastedHTML
44+
if (fn) out = fn.call(plugin.props, out, ed.view)
45+
}
46+
return out
47+
}
48+
3849
describe('markdown paste', () => {
3950
it('renders a pasted inline link as a link mark', () => {
4051
editor = mount()
@@ -124,6 +135,7 @@ describe('markdown paste', () => {
124135
['underscore italic', 'an _italic_ word', 'italic'],
125136
['underscore bold', 'a __bold__ word', 'bold'],
126137
['strikethrough', 'a ~~struck~~ word', 'strike'],
138+
['highlight', 'a ==marked== word', 'highlight'],
127139
['inline code', 'some `code` here', 'code'],
128140
['bullet list', '- one\n- two', 'bulletList'],
129141
['ordered list', '1. one\n2. two', 'orderedList'],
@@ -178,4 +190,53 @@ describe('markdown paste', () => {
178190
.filter((type) => type !== 'paragraph')
179191
expect(structural).toEqual(['heading', 'bulletList', 'blockquote'])
180192
})
193+
194+
it('pastes VSCode code (vscode-editor-data) as a fenced code block with its language', () => {
195+
editor = mount()
196+
const code = 'const x: number = 1\nreturn x'
197+
const handled = paste(editor, code, '<div><span>const</span></div>', {
198+
'vscode-editor-data': JSON.stringify({ mode: 'typescript' }),
199+
})
200+
expect(handled).toBe(true)
201+
const block = (editor.getJSON().content ?? []).find((n) => n.type === 'codeBlock')
202+
expect(block).toBeDefined()
203+
expect(block?.attrs?.language).toBe('typescript')
204+
expect(block?.content?.[0]?.text).toBe(code)
205+
})
206+
207+
it.each([
208+
['html', 'markup'],
209+
['shellscript', 'bash'],
210+
])('maps VSCode language id %s to our code-block value %s', (mode, expected) => {
211+
editor = mount()
212+
paste(editor, 'code', '', { 'vscode-editor-data': JSON.stringify({ mode }) })
213+
const block = (editor.getJSON().content ?? []).find((n) => n.type === 'codeBlock')
214+
expect(block?.attrs?.language).toBe(expected)
215+
})
216+
217+
it.each(['markdown', 'md', 'mdx', 'plaintext'])(
218+
'does NOT force a code block for VSCode %s copies (parses as markdown instead)',
219+
(mode) => {
220+
editor = mount()
221+
const handled = paste(editor, '# Title\n\n- item', '', {
222+
'vscode-editor-data': JSON.stringify({ mode }),
223+
})
224+
expect(handled).toBe(true)
225+
const types = (editor.getJSON().content ?? []).map((n) => n.type)
226+
expect(types).not.toContain('codeBlock')
227+
expect(types).toContain('heading')
228+
expect(types).toContain('bulletList')
229+
}
230+
)
231+
232+
it('strips <style>/<script> from pasted HTML so their text never leaks into the doc', () => {
233+
editor = mount()
234+
const gsheets =
235+
'<google-sheets-html-origin><style>td{mso-1:2}</style><table><tr><td>a</td></tr></table></google-sheets-html-origin>'
236+
const cleaned = transformHtml(editor, gsheets)
237+
expect(cleaned).not.toContain('<style>')
238+
expect(cleaned).not.toContain('mso-1')
239+
expect(cleaned).toContain('<td>a</td>')
240+
expect(transformHtml(editor, 'a<script>alert(1)</script>b')).toBe('ab')
241+
})
181242
})

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,61 @@ const INLINE_MARK_HINTS: ReadonlyArray<RegExp> = [
3131
/_[^_\n]+_/,
3232
/~~[^~\n]+~~/,
3333
/`[^`\n]+`/,
34+
/==[^=\n]+==/,
3435
]
3536

3637
function hasAny(hints: ReadonlyArray<RegExp>, text: string): boolean {
3738
return hints.some((hint) => hint.test(text))
3839
}
3940

41+
/**
42+
* VSCode language ids that differ from our code-block language values. `markdown`/`plaintext` map to
43+
* the empty string so they are NOT forced into a code block — markdown copied from VSCode should parse
44+
* as markdown, and plain text should paste as text; other ids pass through as-is.
45+
*/
46+
const VSCODE_LANGUAGE_ALIASES: Readonly<Record<string, string>> = {
47+
html: 'markup',
48+
shellscript: 'bash',
49+
shell: 'bash',
50+
jsonc: 'json',
51+
plaintext: '',
52+
markdown: '',
53+
md: '',
54+
mdx: '',
55+
}
56+
57+
/**
58+
* Extracts the source language from VSCode's `vscode-editor-data` clipboard payload (a JSON blob with a
59+
* `mode` field), mapping the few ids that differ from our code-block values. Returns `''` when the
60+
* payload is absent, unparseable, or a non-code mode (plaintext/markdown). A real code language makes
61+
* the paste handler emit a fenced code block — otherwise VSCode's per-token colored-span HTML would
62+
* fall through to ProseMirror's default parser and flatten into plain paragraphs — while an empty
63+
* result falls through so markdown copied from VSCode still parses as markdown.
64+
*/
65+
function parseVscodeLanguage(data: string | undefined): string {
66+
if (!data) return ''
67+
try {
68+
const mode = (JSON.parse(data) as { mode?: unknown }).mode
69+
if (typeof mode !== 'string') return ''
70+
return VSCODE_LANGUAGE_ALIASES[mode] ?? mode
71+
} catch {
72+
return ''
73+
}
74+
}
75+
76+
/** `<style>`/`<script>` elements (with their content), matched as a pair via the tag backreference. */
77+
const NON_CONTENT_HTML = /<(style|script)\b[\s\S]*?<\/\1>/gi
78+
79+
/**
80+
* Strips `<style>`/`<script>` elements from pasted HTML. Google Sheets and Word prepend a `<style>`
81+
* block of CSS (and Sheets a `<google-sheets-html-origin>` wrapper); ProseMirror's DOM parser has no
82+
* rule for `<style>`, so it would walk the element's CSS text into the document as literal paragraphs.
83+
* Removing these before parsing keeps the pasted content clean (PM already discards unknown wrappers).
84+
*/
85+
function stripNonContentHtml(html: string): string {
86+
return html.replace(NON_CONTENT_HTML, '')
87+
}
88+
4089
/**
4190
* Parses pasted plain text that looks like markdown into rich content, via the strict CommonMark
4291
* parser ({@link parseMarkdownToDoc}, `marked`). Pastes inside a code block or inline code are left
@@ -62,11 +111,20 @@ export const MarkdownPaste = Extension.create({
62111
return [
63112
new Plugin({
64113
props: {
114+
transformPastedHTML: (html) => stripNonContentHtml(html),
65115
handlePaste: (_view, event) => {
66116
if (!editor.isEditable) return false
67117
if (editor.isActive('codeBlock') || editor.isActive('code')) return false
68118
const text = event.clipboardData?.getData('text/plain')
69119
if (!text) return false
120+
const language = parseVscodeLanguage(event.clipboardData?.getData('vscode-editor-data'))
121+
if (language) {
122+
return editor.commands.insertContent({
123+
type: 'codeBlock',
124+
attrs: { language },
125+
content: [{ type: 'text', text }],
126+
})
127+
}
70128
if (!hasAny(STRUCTURAL_MARKDOWN_HINTS, text)) {
71129
if (!hasAny(INLINE_MARK_HINTS, text)) return false
72130
if (event.clipboardData?.getData('text/html')) return false

0 commit comments

Comments
 (0)