Skip to content

Commit 3c5024f

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.
1 parent e6d9bfa commit 3c5024f

2 files changed

Lines changed: 119 additions & 2 deletions

File tree

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

Lines changed: 62 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()
@@ -178,4 +189,53 @@ describe('markdown paste', () => {
178189
.filter((type) => type !== 'paragraph')
179190
expect(structural).toEqual(['heading', 'bulletList', 'blockquote'])
180191
})
192+
193+
it('pastes VSCode code (vscode-editor-data) as a fenced code block with its language', () => {
194+
editor = mount()
195+
const code = 'const x: number = 1\nreturn x'
196+
const handled = paste(editor, code, '<div><span>const</span></div>', {
197+
'vscode-editor-data': JSON.stringify({ mode: 'typescript' }),
198+
})
199+
expect(handled).toBe(true)
200+
const block = (editor.getJSON().content ?? []).find((n) => n.type === 'codeBlock')
201+
expect(block).toBeDefined()
202+
expect(block?.attrs?.language).toBe('typescript')
203+
expect(block?.content?.[0]?.text).toBe(code)
204+
})
205+
206+
it.each([
207+
['html', 'markup'],
208+
['shellscript', 'bash'],
209+
])('maps VSCode language id %s to our code-block value %s', (mode, expected) => {
210+
editor = mount()
211+
paste(editor, 'code', '', { 'vscode-editor-data': JSON.stringify({ mode }) })
212+
const block = (editor.getJSON().content ?? []).find((n) => n.type === 'codeBlock')
213+
expect(block?.attrs?.language).toBe(expected)
214+
})
215+
216+
it.each(['markdown', 'md', 'mdx', 'plaintext'])(
217+
'does NOT force a code block for VSCode %s copies (parses as markdown instead)',
218+
(mode) => {
219+
editor = mount()
220+
const handled = paste(editor, '# Title\n\n- item', '', {
221+
'vscode-editor-data': JSON.stringify({ mode }),
222+
})
223+
expect(handled).toBe(true)
224+
const types = (editor.getJSON().content ?? []).map((n) => n.type)
225+
expect(types).not.toContain('codeBlock')
226+
expect(types).toContain('heading')
227+
expect(types).toContain('bulletList')
228+
}
229+
)
230+
231+
it('strips <style>/<script> from pasted HTML so their text never leaks into the doc', () => {
232+
editor = mount()
233+
const gsheets =
234+
'<google-sheets-html-origin><style>td{mso-1:2}</style><table><tr><td>a</td></tr></table></google-sheets-html-origin>'
235+
const cleaned = transformHtml(editor, gsheets)
236+
expect(cleaned).not.toContain('<style>')
237+
expect(cleaned).not.toContain('mso-1')
238+
expect(cleaned).toContain('<td>a</td>')
239+
expect(transformHtml(editor, 'a<script>alert(1)</script>b')).toBe('ab')
240+
})
181241
})

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,54 @@ function hasAny(hints: ReadonlyArray<RegExp>, text: string): boolean {
3737
return hints.some((hint) => hint.test(text))
3838
}
3939

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

0 commit comments

Comments
 (0)