fix(message-editor): stop pasted CSS and escapes leaking into composer#3089
Conversation
Pasting rich text into the chat composer inserted garbage before the real
text. Two causes in the HTML->Markdown paste conversion (Turndown):
- macOS puts a `<style>` block on the clipboard when copying from native
apps (Notes, Mail, Slack). Turndown emitted its CSS as text, so pasting
"Yo dude" produced
"p.p1 {margin: 0.0px ...; font: 18.0px Helvetica}\n\nYo dude".
Fixed by removing non-content elements (style/script/head/title/meta/link).
- Turndown's default escaping backslash-mangled ordinary punctuation
("1." -> "1\.", "snake_case" -> "snake\_case", "[x]" -> "\[x\]"). Because
the escaped output no longer equalled the plain-text fallback, plain
pastes were also forced down the inline-Markdown path instead of the
editor's native paste. Disabling escaping keeps plain text intact so it
equals the fallback and defers to native paste; only genuinely formatted
content (bold, lists, links, tables, code) takes the Markdown path.
Added regression tests covering the macOS style-block leak, the escaping
cases, and that real formatting is still preserved. Verified against the
running app's bundled module over CDP.
Generated-By: PostHog Code
Task-Id: 0f5214ac-2b22-4769-92cb-39967a9b67ed
|
Merging to
After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here |
|
React Doctor found no issues in the changed files. 🎉 Reviewed by React Doctor for commit |
|
Reviews (1): Last reviewed commit: "fix(message-editor): stop pasted CSS and..." | Re-trigger Greptile |
| // text — "1." -> "1\.", "snake_case" -> "snake\_case", "[x]" -> "\[x\]". | ||
| // Disabling it keeps plain text intact so it also stays equal to the | ||
| // plain-text fallback below and defers to the editor's native paste. | ||
| turndown.escape = (text) => text; |
There was a problem hiding this comment.
Unescaped metacharacters can corrupt rendering in formatted pastes
Setting escape to a no-op prevents the backslash-mangling fix for plain text, but it also means *, _, [, and ] are never escaped in text nodes that sit alongside real Markdown formatting. When the result isn't equal to the plain-text fallback it gets returned as Markdown and rendered by Tiptap. A paste like <p>formula a*b and <strong>c</strong></p> produces formula a*b and **c**; if the *b and **c span looks like a valid CommonMark emphasis run to the parser, the rendered output will differ from the intended text. The risk is narrow (requires Markdown-special chars next to real HTML formatting) but it's a real edge case that no test currently covers.
| it("preserves real formatting without escaping surrounding punctuation", () => { | ||
| const html = "<p>See <strong>item_1.</strong> in arr[0]</p>"; | ||
| expect(htmlToMarkdown(html, "See item_1. in arr[0]")).toBe( | ||
| "See **item_1.** in arr[0]", | ||
| ); | ||
| }); |
There was a problem hiding this comment.
Standalone
it where a parameterised case would be more consistent
The project prefers parameterised tests. This scenario (HTML with formatting → expected Markdown output) has the same shape as the existing it.each block at the top of the file, and could be expressed as another [label, html, plainFallback, expected] row there. Keeping it as a one-off it means future similar cases also grow as individual tests rather than rows.
Context Used: Do not attempt to comment on incorrect alphabetica... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
… text VS Code & co put syntax-highlighted text/html on the clipboard whose only structure is a white-space:pre wrapper with one <div> per line. Neither paste path handles that shape faithfully: Turndown converts the line divs into paragraphs, so the inserted Markdown gains a blank line between every code line, and ProseMirror's native HTML paste splits the lines into one paragraph each, which the composer serializes back with blank lines too. (#3089 fixed the backslash-escaping half of this; multi-line copies were still mangled.) Detect the code-editor clipboard shape (single white-space:pre block or bare <pre>, div/span/br content only) and insert the clipboard's plain text verbatim instead. Web code blocks (<pre><code>) keep the existing fenced-Markdown conversion. Generated-By: PostHog Code Task-Id: ad31c8f6-d523-4079-b13e-1f8aa419d8d9
Problem
Pasting rich text into the chat composer inserted garbage before the intended text. Reported case: pasting produced
Root cause
Both bugs live in the HTML→Markdown paste conversion (
htmlToMarkdown/ Turndown) inpackages/ui/src/features/message-editor/utils/htmlToMarkdown.ts, used by the TiptaphandlePastehandler.<style>leak. When you copy rich text from a native macOS app (Notes, Mail, Slack), the clipboardtext/htmlcontains a<style>block. Turndown has no rule for<style>, so it emitted the CSS as text — the style block, then the real text.1.→1\.,snake_case→snake\_case,[x]→\[x\]. Worse, the escaped output no longer equalled the plain-text fallback, so the equality guard failed and ordinary pastes were forced down the inline-Markdown path instead of the editor's native paste.Fix
turndown.remove(["style", "script", "head", "title", "meta", "link"])— drop non-content elements so their text never reaches the paste.turndown.escape = (text) => text— the composer is plain-text; disabling escaping keeps ordinary text intact, so it equals the plain-text fallback → returnsnull→ defers to native paste. Only genuinely structural HTML (bold, lists, links, tables, code) takes the Markdown path.Testing
htmlToMarkdown.test.tspass; the new ones fail if either fix is reverted.biome lintclean on both files.htmlToMarkdownmodule:<style>…p.p1 {…}+ fallback →null(defers → cleanYo dude); no fallback →"Yo dude".snake_case_name and 1. item [x]→null(no mangling).See <strong>bold</strong> … <a>link</a>→See **bold** and [link](https://x.com)(formatting preserved).🤖 Generated with Claude Code