0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-10 23:00:09 +08:00
discourse/spec/system/composer/prosemirror_pasting_spec.rb
Renato Atilio d62cc48d9c
DEV: to-markdown prosemirror HTML parsing (#35563)
Replaces the custom ~1000-line `to-markdown` converter with ProseMirror's schema-based parsing and serialization pipeline, reusing the same extensions that power the rich editor. The old converter duplicated logic the extensions already implement (image formatting, mention/hashtag serialization, table handling, list nesting, etc.) — a single pipeline means one set of rules, fewer divergence bugs, and plugin extensions work in both directions automatically.

### Core change

- `toMarkdown()` is now async and lazy-loads ProseMirror on first use.
- Cooked→markdown serialization runs through the registered rich-editor extensions, so both conversion directions share one source of truth.

### QuoteState and quote callers

Making `toMarkdown()` async changed the quote flow. `QuoteState` now stores the selection's HTML and exposes an async `markdown()`; `buffer` is the plain-text selection. Every caller that needs markdown was updated to await `markdown()`:

- Topic controller (`selectText`, `replyToPost` including EmbedMode, `replyAsNewTopic`, `buildQuoteMarkdown`).
- Nested controller — forwards the full selection state via the new `copyFrom`.
- Fast-edit modal and `computeSupportsFastEdit`.
- discourse-ai post-helper menu — now sends markdown (not plain text) to the AI endpoints.

`markdown()` snapshots `opts` before awaiting so a concurrent selection change can't mis-pair the result.

### Plugin API

- Removed plugin-specific `addTagDecorateCallback`/`addTextDecorateCallback` usage from local-dates and spoiler-alert (the old APIs are kept as deprecated no-ops). local-dates now uses a `transformParsedHTML` rich-editor extension hook.

### Extractions and sharing

- Word paste handling moved into a dedicated `word-paste.js` extension.
- Quote-selection list-structure preservation moved into `preserve-list-structure.js`.
- `normalizeTable` is shared between the paste plugin and the serializer for consistent table output across editor modes.

### Quoting fix

- `selectedHTML` drops the empty trailing block a triple-click leaves behind. A triple-click extends the selection's end to the start of the following block, so `cloneContents()` would otherwise clone an empty `<blockquote>` that serializes to a stray `> ` in the quote.
2026-05-29 18:14:32 -03:00

409 lines
14 KiB
Ruby
Vendored

# frozen_string_literal: true
describe "Composer - ProseMirror - Pasting content" do
include_context "with prosemirror editor"
it "creates a mention when pasting an HTML anchor with class mention" do
cdp.allow_clipboard
open_composer
html = %(<a href="/u/#{current_user.username}" class="mention">@#{current_user.username}</a>)
cdp.copy_paste(html, html: true)
expect(rich).to have_css("a.mention", text: current_user.username)
expect(rich).to have_css("a.mention[data-name='#{current_user.username}']")
expect(rich).to have_no_css("a.mention[href]")
composer.toggle_rich_editor
expect(composer).to have_value("@#{current_user.username}")
end
it "does not freeze the editor when pasting markdown code blocks without a language" do
with_logs do |logger|
open_composer
# The example is a bit convoluted, but it's the simplest way to reproduce the issue.
composer.type_content("This is a test\n\n")
cdp.copy_paste <<~MARKDOWN
```
puts SiteSetting.all_settings(filter_categories: ["uncategorized"]).map { |setting| setting[:setting] }.join("\n")
```
MARKDOWN
expect(logger.logs.map { |log| log[:message] }).not_to include(
"Maximum call stack size exceeded",
)
expect(rich).to have_css("pre code")
expect(rich).to have_css("select.code-language-select")
end
end
it "parses images copied from cooked with base62-sha1" do
cdp.allow_clipboard
open_composer
cdp.copy_paste('<img src="image.png" alt="alt text" data-base62-sha1="1234567890">', html: true)
expect(rich).to have_css(
"img[src$='image.png'][alt='alt text'][data-orig-src='upload://1234567890.png']",
)
end
it "respects existing marks when pasting a url over a selection" do
cdp.allow_clipboard
open_composer
cdp.copy_paste("not selected `code`**bold**not*italic* not selected")
rich.find("strong").double_click
cdp.copy_paste("www.example.com")
expect(rich).to have_css("code", text: "code")
expect(rich).to have_css("strong", text: "bold")
expect(rich).to have_css("em", text: "italic")
composer.toggle_rich_editor
expect(composer).to have_value(
"not selected [`code`**bold**not*italic*](www.example.com) not selected",
)
end
it "auto-links pasted URLs from text/html over a selection" do
cdp.allow_clipboard
open_composer
cdp.copy_paste("not selected **bold** not selected")
rich.find("strong").double_click
cdp.copy_paste("<p>www.example.com</p>", html: true)
composer.toggle_rich_editor
expect(composer).to have_value("not selected **[bold](www.example.com)** not selected")
end
it "removes newlines from alt/title in pasted image" do
cdp.allow_clipboard
open_composer
cdp.copy_paste(<<~HTML, html: true)
<img src="https://example.com/image.png" alt="alt
with new
lines" title="title
with new
lines">
HTML
img = rich.find(".composer-image-node img")
expect(img["src"]).to eq("https://example.com/image.png")
expect(img["alt"]).to eq("alt with new lines")
expect(img["title"]).to eq("title with new lines")
composer.toggle_rich_editor
expect(composer).to have_value(
'![alt with new lines](https://example.com/image.png "title with new lines")',
)
end
it "ignores text/html content if Files are present" do
open_composer
paste_and_click_image
expect(rich).to have_no_css("img[src^='data:']")
composer.focus # making sure the toggle click won't be captured as a double click
composer.toggle_rich_editor
expect(composer).to have_value(%r{\A!\[image\|244x66\]\(upload://\w+\.png\)\z})
end
it "handles multiple data URI images pasted simultaneously" do
SiteSetting.simultaneous_uploads = 1
valid_png_data_uri =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
valid_jpeg_data_uri =
"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/wA=="
cdp.allow_clipboard
open_composer
html = <<~HTML
before 1<br>
<img src="#{valid_png_data_uri}" alt="img1" width="100" height="100">
<img src="#{valid_png_data_uri}" alt="img2">
between<br>
<img src="#{valid_jpeg_data_uri}">
after 2
HTML
cdp.copy_paste(html, html: true)
expect(rich).to have_css("img[alt='img1'][width='100'][height='100'][data-orig-src]")
expect(rich).to have_css("img[alt='img2'][data-orig-src]")
expect(rich).to have_css("img[alt='image'][data-orig-src]")
expect(rich).to have_css("p", text: "before 1")
expect(rich).to have_css("p", text: "between")
expect(rich).to have_css("p", text: "after 2")
expect(rich).to have_no_css("img[src^='data:']")
# pasting a second time to make sure there's no cache pollution
cdp.copy_paste("<img src='#{valid_png_data_uri}' alt='img1'>", html: true)
expect(rich).to have_no_css("img[src^='data:']")
expect(rich).to have_css("img[alt='img1'][data-orig-src]", count: 2)
end
context "when unauthorized to upload" do
before { SiteSetting.authorized_extensions = "" }
it "allows pasting text" do
cdp.allow_clipboard
open_composer
cdp.copy_paste("Just some text")
expect(rich).to have_css("p", text: "Just some text")
composer.toggle_rich_editor
expect(composer).to have_value("Just some text")
end
it "avoids triggering upload for paste" do
open_composer
cdp.allow_clipboard
cdp.copy_test_image
cdp.paste
expect(rich).to have_no_css("img")
composer.toggle_rich_editor
expect(composer).to have_value("")
end
it "avoids triggering upload for base64" do
valid_png_data_uri =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
cdp.allow_clipboard
open_composer
html = <<~HTML
<img src="#{valid_png_data_uri}" alt="img1" width="100" height="100">
HTML
cdp.copy_paste(html, html: true)
expect(rich).to have_no_css("img")
expect(rich).to have_text("image")
composer.toggle_rich_editor
expect(composer).to have_value("image")
end
it "replaces multiple base64 images with same data URI" do
valid_png_data_uri =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
cdp.allow_clipboard
open_composer
html = <<~HTML
<p>before</p>
<img src="#{valid_png_data_uri}" alt="img1">
<p>middle</p>
<img src="#{valid_png_data_uri}" alt="img2">
<p>after</p>
HTML
cdp.copy_paste(html, html: true)
expect(rich).to have_no_css("img")
expect(rich).to have_text("before")
expect(rich).to have_text("middle")
expect(rich).to have_text("after")
composer.toggle_rich_editor
expect(composer).to have_value("before\n\nimage\n\nmiddle\n\nimage\n\nafter")
end
end
it "merges text with link marks created from parsing" do
cdp.allow_clipboard
open_composer
cdp.copy_paste("This is a [link](https://example.com)")
expect(rich).to have_css("a", text: "link")
composer.type_content(:space)
composer.type_content(:left)
composer.type_content(:backspace)
expect(rich).to have_css("a", text: "lin")
end
it "continues a bullet list when pasting multi-line text into an empty list item" do
cdp.allow_clipboard
open_composer
composer.type_content("* ")
cdp.copy_paste("apple\nbanana\ncoconut")
expect(rich).to have_css("ul li", count: 3)
expect(rich).to have_css("ul li", text: "apple")
expect(rich).to have_css("ul li", text: "banana")
expect(rich).to have_css("ul li", text: "coconut")
end
it "continues an ordered list when pasting multi-line text into an empty list item" do
cdp.allow_clipboard
open_composer
composer.type_content("1. ")
cdp.copy_paste("apple\nbanana\ncoconut")
expect(rich).to have_css("ol li", count: 3)
expect(rich).to have_css("ol li", text: "apple")
expect(rich).to have_css("ol li", text: "banana")
expect(rich).to have_css("ol li", text: "coconut")
end
it "skips blank lines when continuing a list from pasted multi-line text" do
cdp.allow_clipboard
open_composer
composer.type_content("* ")
cdp.copy_paste("apple\n\nbanana")
expect(rich).to have_css("ul li", count: 2)
expect(rich).to have_css("ul li", text: "apple")
expect(rich).to have_css("ul li", text: "banana")
end
it "keeps default paste behavior in a non-empty list item" do
cdp.allow_clipboard
open_composer
composer.type_content("* existing")
cdp.copy_paste("\napple\nbanana")
expect(rich).to have_css("ul li", count: 1)
expect(rich).to have_css("ul li", text: "existing")
expect(rich).to have_css("ul li", text: "apple")
expect(rich).to have_css("ul li", text: "banana")
end
it "clears closed marks from stored marks when using markInputRule" do
open_composer
composer.type_content("[`something`](link) word")
expect(rich).to have_css("a", text: "something")
expect(rich).to have_css("a code", text: "something")
expect(rich).to have_content("word")
expect(rich).to have_no_css("code", text: "word")
end
it "parses html inline tags from pasted HTML" do
cdp.allow_clipboard
open_composer
cdp.copy_paste("<mark>mark</mark> my <ins>words</ins> <kbd>ctrl</kbd>", html: true)
expect(rich).to have_css("mark", text: "mark")
expect(rich).to have_css("ins", text: "words")
expect(rich).to have_css("kbd", text: "ctrl")
composer.toggle_rich_editor
expect(composer).to have_value("<mark>mark</mark> my <ins>words</ins> <kbd>ctrl</kbd> ")
end
it "converts newlines to hard breaks when parsing `white-space: pre` HTML" do
cdp.allow_clipboard
open_composer
cdp.copy_paste("<span style='white-space: pre;'>line1\nline2\nline3</pre>", html: true)
expect(rich).to have_css("p", text: "line1")
expect(rich).to have_css("p", text: "line2")
expect(rich).to have_css("p", text: "line3")
expect(rich).to have_css("br", count: 2)
composer.toggle_rich_editor
expect(composer).to have_value("line1\nline2\nline3")
end
context "when pasting tables" do
it "fills incomplete rows" do
cdp.allow_clipboard
open_composer
html = <<~HTML
<table>
<tbody>
<tr><td>Title</td></tr>
<tr><th>Column A</th><td>Column B</td></tr>
<tr><th>Value 1</th><td>Value 2</td></tr>
<tr><td colspan="2"></td></tr>
<tr><th>Value 3</th><td>Value 4</td></tr>
</tbody>
</table>
HTML
cdp.copy_paste(html, html: true)
composer.toggle_rich_editor
markdown = <<~MARKDOWN
| Title | |
|----|----|
| Column A | Column B |
| Value 1 | Value 2 |
| | |
| Value 3 | Value 4 |
MARKDOWN
expect(composer).to have_value(markdown)
end
it "normalizes column counts when header has fewer columns than body rows" do
cdp.allow_clipboard
open_composer
html = <<~HTML
<table>
<tbody>
<tr><th>Header1</th><th>Header2</th></tr>
<tr><td>Cell1</td><td>Cell2</td><td>Cell3</td><td>Cell4</td></tr>
<tr><td>Data1</td><td>Data2</td><td>Data3</td><td>Data4</td></tr>
</tbody>
</table>
HTML
cdp.copy_paste(html, html: true)
composer.toggle_rich_editor
markdown = <<~MARKDOWN
| Header1 | Header2 | | |
|----|----|----|----|
| Cell1 | Cell2 | Cell3 | Cell4 |
| Data1 | Data2 | Data3 | Data4 |
MARKDOWN
expect(composer).to have_value(markdown)
end
it "normalizes nested table column counts" do
cdp.allow_clipboard
open_composer
html = <<~HTML
<table>
<tbody>
<tr>
<td>
<table>
<tbody>
<tr><td>CLOSED DOWNSTREAM</td></tr>
<tr><td colspan="2"></td></tr>
<tr><th>Alias:</th><td>None</td></tr>
<tr><th>Product:</th><td>name</td></tr>
<tr><th>Component:</th><td>general</td></tr>
</tbody>
</table>
</td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
HTML
cdp.copy_paste(html, html: true)
composer.toggle_rich_editor
# Nested tables are flattened - the inner table content is preserved
# The outer table structure is dropped since ProseMirror can't represent nested tables
markdown = <<~MARKDOWN
| CLOSED DOWNSTREAM | |
|----|----|
| | |
| Alias: | None |
| Product: | name |
| Component: | general |
MARKDOWN
expect(composer).to have_value(markdown)
end
it "handles tables with empty rows without crashing" do
cdp.allow_clipboard
open_composer
html = <<~HTML
<table>
<tbody>
<tr></tr>
</tbody>
</table>
<p>After table</p>
HTML
cdp.copy_paste(html, html: true)
composer.toggle_rich_editor
expect(composer).to have_value("After table")
end
end
end