Skip to content

Commit 2cc6351

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. - 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 5bed2ef commit 2cc6351

2 files changed

Lines changed: 97 additions & 2 deletions

File tree

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

Lines changed: 47 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,38 @@ 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('strips <style>/<script> from pasted HTML so their text never leaks into the doc', () => {
217+
editor = mount()
218+
const gsheets =
219+
'<google-sheets-html-origin><style>td{mso-1:2}</style><table><tr><td>a</td></tr></table></google-sheets-html-origin>'
220+
const cleaned = transformHtml(editor, gsheets)
221+
expect(cleaned).not.toContain('<style>')
222+
expect(cleaned).not.toContain('mso-1')
223+
expect(cleaned).toContain('<td>a</td>')
224+
expect(transformHtml(editor, 'a<script>alert(1)</script>b')).toBe('ab')
225+
})
181226
})

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

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

40+
/** VSCode language ids that differ from our code-block language values; others pass through as-is. */
41+
const VSCODE_LANGUAGE_ALIASES: Readonly<Record<string, string>> = {
42+
html: 'markup',
43+
shellscript: 'bash',
44+
shell: 'bash',
45+
jsonc: 'json',
46+
plaintext: '',
47+
}
48+
49+
/**
50+
* Extracts the source language from VSCode's `vscode-editor-data` clipboard payload (a JSON blob with a
51+
* `mode` field), mapping the few ids that differ from our code-block values. Returns `''` (plain) when
52+
* the payload is absent, unparseable, or plaintext. When present, the paste handler emits a fenced code
53+
* block with this language rather than letting VSCode's per-token colored-span HTML fall through to
54+
* ProseMirror's default parser, which would flatten it into plain paragraphs.
55+
*/
56+
function parseVscodeLanguage(data: string | undefined): string {
57+
if (!data) return ''
58+
try {
59+
const mode = (JSON.parse(data) as { mode?: unknown }).mode
60+
if (typeof mode !== 'string') return ''
61+
return VSCODE_LANGUAGE_ALIASES[mode] ?? mode
62+
} catch {
63+
return ''
64+
}
65+
}
66+
67+
/** `<style>`/`<script>` elements (with their content), matched as a pair via the tag backreference. */
68+
const NON_CONTENT_HTML = /<(style|script)\b[\s\S]*?<\/\1>/gi
69+
70+
/**
71+
* Strips `<style>`/`<script>` elements from pasted HTML. Google Sheets and Word prepend a `<style>`
72+
* block of CSS (and Sheets a `<google-sheets-html-origin>` wrapper); ProseMirror's DOM parser has no
73+
* rule for `<style>`, so it would walk the element's CSS text into the document as literal paragraphs.
74+
* Removing these before parsing keeps the pasted content clean (PM already discards unknown wrappers).
75+
*/
76+
function stripNonContentHtml(html: string): string {
77+
return html.replace(NON_CONTENT_HTML, '')
78+
}
79+
4080
/**
4181
* Parses pasted plain text that looks like markdown into rich content, via the strict CommonMark
4282
* parser ({@link parseMarkdownToDoc}, `marked`). Pastes inside a code block or inline code are left
@@ -62,11 +102,21 @@ export const MarkdownPaste = Extension.create({
62102
return [
63103
new Plugin({
64104
props: {
105+
transformPastedHTML: (html) => stripNonContentHtml(html),
65106
handlePaste: (_view, event) => {
66107
if (!editor.isEditable) return false
67108
if (editor.isActive('codeBlock') || editor.isActive('code')) return false
68109
const text = event.clipboardData?.getData('text/plain')
69110
if (!text) return false
111+
const vscodeData = event.clipboardData?.getData('vscode-editor-data')
112+
if (vscodeData) {
113+
const language = parseVscodeLanguage(vscodeData)
114+
return editor.commands.insertContent({
115+
type: 'codeBlock',
116+
...(language ? { attrs: { language } } : {}),
117+
content: [{ type: 'text', text }],
118+
})
119+
}
70120
if (!hasAny(STRUCTURAL_MARKDOWN_HINTS, text)) {
71121
if (!hasAny(INLINE_MARK_HINTS, text)) return false
72122
if (event.clipboardData?.getData('text/html')) return false

0 commit comments

Comments
 (0)