mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 13:08:40 +08:00
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.
172 lines
6.2 KiB
Ruby
Vendored
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
|