0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 13:08:40 +08:00
discourse/spec/system/post_selection_copy_quote_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

172 lines
6.2 KiB
Ruby
Vendored

# frozen_string_literal: true
describe "Post selection | Copy quote" do
let(:topic_page) { PageObjects::Pages::Topic.new }
let(:composer) { PageObjects::Components::Composer.new }
let(:cdp) { PageObjects::CDP.new }
let(:toasts) { PageObjects::Components::Toasts.new }
fab!(:topic)
fab!(:post) { Fabricate(:post, topic: topic, raw: "Hello world this is time for quoting") }
fab!(:current_user, :admin)
def select_list_items(post_selector, start_li_index, end_li_index)
js = <<-JS
const cooked = document.querySelector(arguments[0]);
const listItems = cooked.querySelectorAll('li');
const startLi = listItems[arguments[1]];
const endLi = listItems[arguments[2]];
// Find the first text node in the li (handles both tight and loose lists)
function findTextNode(element) {
if (element.nodeType === Node.TEXT_NODE && element.textContent.trim()) {
return element;
}
for (const child of element.childNodes) {
const found = findTextNode(child);
if (found) return found;
}
return null;
}
const startNode = findTextNode(startLi);
const endNode = findTextNode(endLi);
const selection = window.getSelection();
const range = document.createRange();
range.setStart(startNode, 0);
range.setEnd(endNode, endNode.textContent.length);
selection.removeAllRanges();
selection.addRange(range);
JS
page.execute_script(js, post_selector, start_li_index, end_li_index)
end
context "when logged in" do
before do
sign_in(current_user)
cdp.allow_clipboard
end
it "copies the selection from the post the clipboard" do
topic_page.visit_topic(topic)
select_text_range("#{topic_page.post_by_number_selector(1)} .cooked p", 0, 10)
topic_page.copy_quote_button.click
expect(toasts).to have_success(I18n.t("js.post.quote_copied_to_clipboard"))
cdp.clipboard_has_text?(<<~QUOTE.chomp, chomp: true)
[quote=\"#{post.user.username}, post:1, topic:#{topic.id}\"]\nHello worl\n[/quote]\n
QUOTE
end
it "does not show the copy quote button if quoting has been disabled by the user" do
current_user.user_option.update!(enable_quoting: false)
topic_page.visit_topic(topic)
select_text_range("#{topic_page.post_by_number_selector(1)} .cooked p", 0, 10)
expect(page).not_to have_css(topic_page.copy_quote_button_selector)
end
it "resets the quote state when the toolbar is hidden" do
topic_page.visit_topic(topic)
select_text_range("#{topic_page.post_by_number_selector(1)} .cooked p", 0, 10)
expect(page).to have_css(topic_page.copy_quote_button_selector)
select_text_range(".topic-map__stat-label", 0, 1) # select non cooked content
topic_page.click_reply_button
expect(composer).to have_value("")
end
it "inserts a partial quote when selecting part of post content" do
topic_page.visit_topic(topic)
select_text_range("#{topic_page.post_by_number_selector(1)} .cooked p", 0, 10)
find(".quote-button .insert-quote").click
expect(composer).to have_value(
%([quote="#{post.user.username}, post:1, topic:#{topic.id}"]\nHello worl\n[/quote]\n\n),
)
end
it "quotes formatted content as markdown with full:true" do
formatted_post = Fabricate(:post, topic: topic, raw: "This is **bold** and *italic* text")
topic_page.visit_topic(topic)
select_all_content(
"#{topic_page.post_by_number_selector(formatted_post.post_number)} .cooked",
)
find(".quote-button .insert-quote").click
composer_value = find(".d-editor-input", visible: :all).value
expect(composer_value).to include("full:true")
expect(composer_value).to include("**bold**")
expect(composer_value).to include("*italic*")
end
it "preserves list formatting when quoting various list types" do
# Post with multiple list types:
# - Loose list (items 0-1): has blank lines between items
# - Tight list (items 2-3): no blank lines
# - Nested list with custom start (items 4-7): 100. with nested tight bullet list
list_post = Fabricate(:post, topic: topic, raw: <<~MD, user: current_user)
Loose list:
1. First loose
2. Second loose
Tight list:
1. First tight
2. Second tight
Nested with start:
100. Hundred
101. Hundred one
- nested hello
- nested world
MD
topic_page.visit_topic(topic)
post_selector = "#{topic_page.post_by_number_selector(list_post.post_number)} .cooked"
# Test 1: Loose list stays loose (items 0-1)
select_list_items(post_selector, 0, 1)
topic_page.copy_quote_button.click
expect(toasts).to have_success(I18n.t("js.post.quote_copied_to_clipboard"))
cdp.clipboard_has_text?("1. First loose\n\n2. Second loose", strict: false)
# Test 2: Tight list stays tight (items 2-3)
select_list_items(post_selector, 2, 3)
topic_page.copy_quote_button.click
expect(toasts).to have_success(I18n.t("js.post.quote_copied_to_clipboard"))
cdp.clipboard_has_text?("1. First tight\n2. Second tight", strict: false)
# Test 3: Nested list preserves start number (items 4-5 are 100, 101)
select_list_items(post_selector, 4, 5)
topic_page.copy_quote_button.click
expect(toasts).to have_success(I18n.t("js.post.quote_copied_to_clipboard"))
cdp.clipboard_has_text?("100. Hundred\n101. Hundred one", strict: false)
# Test 4: Nested tight bullet list stays tight (items 6-7)
select_list_items(post_selector, 6, 7)
topic_page.copy_quote_button.click
expect(toasts).to have_success(I18n.t("js.post.quote_copied_to_clipboard"))
cdp.clipboard_has_text?("* nested hello\n* nested world", strict: false)
end
end
context "when anon" do
it "does not show the copy quote button to anon users" do
topic_page.visit_topic(topic)
select_text_range("#{topic_page.post_by_number_selector(1)} .cooked p", 0, 10)
expect(page).not_to have_css(topic_page.copy_quote_button_selector)
end
end
end