mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-26 06:38:28 +08:00
Adds `commands` and `state` keys to the `RichEditorExtension` and uses
them for the spoiler toolbar item, so it shows the current state when
inside a spoiler node, and toggles the state when triggered.
Improves the `keymap` key chaining mechanism to also include the base
keymap as part of the chain, so custom commands can be triggered before
getting to the base keymap.
Extends the ENTER key handling with a different behavior for inline
nodes such as `[spoiler]` or `<kbd>` (inline spoiler and inline html):
- if at the start of a node, moves the node to a paragraph below
- if in the middle of the node, splits it but strips the node from the
second half, keeping it "unstyled"
- if at the end of the node, adds and selects a new paragraph
Appends an empty space (" ") after these inline nodes when they happen
at the end of a block node, allowing to tap/right-arrow out of the
inline node.
Adds a `[spoiler]` input rule which adds an inline spoiler if
mid-sentence or a block spoiler if at the beginning of a text block.
Replaces the "toggle" for revealing the spoiler node, instead of using a
click handler we now track the cursor position and reveal when it's
inside a spoiler node.
Tweaks the `bbcode-block` markdown-it handler to avoid applying a bbcode
block for a scenario like `[spoiler]spoiled[/spoiler] `, with the
trailing space, otherwise our inline node on ProseMirror would be
re-parsed as block on the next markdown-it parsing.
Updates the styling using the default `--d-border-radius`.
<img width="725" height="234" alt="image"
src="https://github.com/user-attachments/assets/6a509fe0-73e8-4f01-98c9-ed96d087ce1c"
/>
1644 lines
52 KiB
Ruby
Vendored
1644 lines
52 KiB
Ruby
Vendored
# frozen_string_literal: true
|
||
|
||
describe "Composer - ProseMirror editor", type: :system do
|
||
fab!(:current_user) do
|
||
Fabricate(
|
||
:user,
|
||
refresh_auto_groups: true,
|
||
composition_mode: UserOption.composition_mode_types[:rich],
|
||
)
|
||
end
|
||
fab!(:tag)
|
||
|
||
let(:cdp) { PageObjects::CDP.new }
|
||
let(:composer) { PageObjects::Components::Composer.new }
|
||
let(:rich) { composer.rich_editor }
|
||
|
||
before { sign_in(current_user) }
|
||
|
||
def open_composer
|
||
page.visit "/new-topic"
|
||
expect(composer).to be_opened
|
||
composer.focus
|
||
end
|
||
|
||
def paste_and_click_image
|
||
# This helper can only be used reliably to paste a single image when no other images are present.
|
||
expect(rich).to have_no_css(".composer-image-node img")
|
||
|
||
cdp.allow_clipboard
|
||
cdp.copy_test_image
|
||
cdp.paste
|
||
|
||
expect(rich).to have_css(".composer-image-node img", count: 1)
|
||
expect(rich).to have_no_css(".composer-image-node img[src='/images/transparent.png']")
|
||
expect(rich).to have_no_css(".composer-image-node img[data-placeholder='true']")
|
||
|
||
rich.find(".composer-image-node img").click
|
||
|
||
expect(rich).to have_css(".composer-image-node .fk-d-menu", count: 2)
|
||
end
|
||
|
||
it "hides the Composer container's preview button" do
|
||
page.visit "/new-topic"
|
||
|
||
expect(composer).to be_opened
|
||
expect(composer).to have_no_composer_preview_toggle
|
||
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_composer_preview_toggle
|
||
end
|
||
|
||
it "saves the user's rich editor preference and remembers it when reopening the composer" do
|
||
open_composer
|
||
expect(composer).to have_rich_editor_active
|
||
composer.toggle_rich_editor
|
||
expect(composer).to have_markdown_editor_active
|
||
|
||
try_until_success(frequency: 0.5) do
|
||
expect(current_user.user_option.reload.composition_mode).to eq(
|
||
UserOption.composition_mode_types[:markdown],
|
||
)
|
||
end
|
||
|
||
visit("/")
|
||
open_composer
|
||
expect(composer).to have_markdown_editor_active
|
||
end
|
||
|
||
it "remembers the user's rich editor preference when starting a new PM" do
|
||
current_user.user_option.update!(composition_mode: UserOption.composition_mode_types[:rich])
|
||
page.visit("/u/#{current_user.username}/messages")
|
||
find(".new-private-message").click
|
||
expect(composer).to be_opened
|
||
expect(composer).to have_rich_editor_active
|
||
end
|
||
|
||
# TODO (martin) Remove this once we are sure all users have migrated
|
||
# to the new rich editor preference, or a few months after the 3.5 release.
|
||
it "saves the old keyValueStore editor preference to the database" do
|
||
visit "/"
|
||
|
||
page.execute_script "window.localStorage.setItem('discourse_d-editor-prefers-rich-editor', 'true');"
|
||
|
||
expect(
|
||
page.evaluate_script("window.localStorage.getItem('discourse_d-editor-prefers-rich-editor')"),
|
||
).to eq("true")
|
||
|
||
open_composer
|
||
|
||
expect(composer).to have_rich_editor
|
||
|
||
try_until_success(frequency: 0.5) do
|
||
expect(current_user.user_option.reload.composition_mode).to eq(
|
||
UserOption.composition_mode_types[:rich],
|
||
)
|
||
end
|
||
|
||
expect(
|
||
page.evaluate_script(
|
||
"window.localStorage.getItem('discourse_d-editor-prefers-rich-editor') === null",
|
||
),
|
||
).to eq(true)
|
||
end
|
||
|
||
context "with autocomplete" do
|
||
it "triggers an autocomplete on mention" do
|
||
open_composer
|
||
composer.type_content("@#{current_user.username}")
|
||
|
||
expect(composer).to have_mention_autocomplete
|
||
end
|
||
|
||
it "triggers an autocomplete on hashtag" do
|
||
open_composer
|
||
composer.type_content("##{tag.name}")
|
||
|
||
expect(composer).to have_hashtag_autocomplete
|
||
end
|
||
|
||
it "triggers an autocomplete on emoji" do
|
||
open_composer
|
||
composer.type_content(":smile")
|
||
|
||
expect(composer).to have_emoji_autocomplete
|
||
end
|
||
|
||
it "strips partially written emoji when using 'more' emoji modal" do
|
||
open_composer
|
||
|
||
composer.type_content("Why :repeat_single")
|
||
|
||
expect(composer).to have_emoji_autocomplete
|
||
|
||
# "more" emoji picker
|
||
composer.send_keys(:down, :enter)
|
||
find("img[data-emoji='repeat_single_button']").click
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_value("Why :repeat_single_button: ")
|
||
end
|
||
end
|
||
|
||
context "with composer messages" do
|
||
fab!(:category)
|
||
|
||
it "shows a popup" do
|
||
open_composer
|
||
composer.type_content("Maybe @staff can help?")
|
||
|
||
expect(composer).to have_popup_content(
|
||
I18n.t("js.composer.cannot_see_group_mention.not_mentionable", group: "staff"),
|
||
)
|
||
end
|
||
end
|
||
|
||
context "with inputRules" do
|
||
it "supports > to create a blockquote" do
|
||
open_composer
|
||
composer.type_content("> This is a blockquote")
|
||
|
||
expect(rich).to have_css("blockquote", text: "This is a blockquote")
|
||
end
|
||
|
||
it "supports n. to create an ordered list" do
|
||
open_composer
|
||
composer.type_content("1. Item 1\n5. Item 2")
|
||
|
||
expect(rich).to have_css("ol li", text: "Item 1")
|
||
expect(find("ol ol", text: "Item 2")["start"]).to eq(5)
|
||
end
|
||
|
||
it "supports *, - or + to create an unordered list" do
|
||
open_composer
|
||
composer.type_content("* Item 1\n")
|
||
composer.type_content("- Item 2\n")
|
||
composer.type_content("+ Item 3")
|
||
|
||
expect(rich).to have_css("ul ul li", count: 3)
|
||
end
|
||
|
||
it "uses 'tight' lists for both ordered and unordered lists by default" do
|
||
open_composer
|
||
composer.type_content("1. Item 1\n5. Item 2\n\n")
|
||
composer.type_content("* Item 1\n* Item 2")
|
||
expect(rich).to have_css("ol[data-tight='true']")
|
||
expect(rich).to have_css("ul[data-tight='true']")
|
||
end
|
||
|
||
it "supports ``` or 4 spaces to create a code block" do
|
||
open_composer
|
||
composer.type_content("```\nThis is a code block")
|
||
composer.send_keys(%i[shift enter])
|
||
composer.type_content(" This is a code block")
|
||
|
||
expect(rich).to have_css("pre code", text: "This is a code block", count: 2)
|
||
end
|
||
|
||
it "supports 1-6 #s to create a heading" do
|
||
open_composer
|
||
composer.type_content("# Heading 1\n")
|
||
composer.type_content("## Heading 2\n")
|
||
composer.type_content("### Heading 3\n")
|
||
composer.type_content("#### Heading 4\n")
|
||
composer.type_content("##### Heading 5\n")
|
||
composer.type_content("###### Heading 6\n")
|
||
|
||
expect(rich).to have_css("h1", text: "Heading 1")
|
||
expect(rich).to have_css("h2", text: "Heading 2")
|
||
expect(rich).to have_css("h3", text: "Heading 3")
|
||
expect(rich).to have_css("h4", text: "Heading 4")
|
||
expect(rich).to have_css("h5", text: "Heading 5")
|
||
expect(rich).to have_css("h6", text: "Heading 6")
|
||
end
|
||
|
||
it "supports _ or * to create an italic text" do
|
||
open_composer
|
||
composer.type_content("_This is italic_\n")
|
||
composer.type_content("Hey _This is italic_\n")
|
||
composer.type_content("*This is italic*\n")
|
||
composer.type_content("Hey*This is italic*\n")
|
||
|
||
expect(rich).to have_css("em", text: "This is italic", count: 4)
|
||
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_value(
|
||
"*This is italic*\n\nHey *This is italic*\n\n*This is italic*\n\nHey*This is italic*",
|
||
)
|
||
end
|
||
|
||
it "supports __ or ** to create a bold text" do
|
||
open_composer
|
||
composer.type_content("__This is bold__\n\n")
|
||
composer.type_content("**This is bold**\n\n")
|
||
composer.type_content("Hey __This is bold__\n\n")
|
||
composer.type_content("Hey**This is bold**")
|
||
|
||
expect(rich).to have_css("strong", text: "This is bold", count: 4)
|
||
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_value(
|
||
"**This is bold**\n\n**This is bold**\n\nHey **This is bold**\n\nHey**This is bold**",
|
||
)
|
||
end
|
||
|
||
it "supports ` to create a code text" do
|
||
open_composer
|
||
composer.type_content("`This is code`")
|
||
|
||
expect(rich).to have_css("code", text: "This is code")
|
||
end
|
||
|
||
it "supports typographer replacements" do
|
||
open_composer
|
||
composer.type_content(
|
||
"foo +- bar... test???? wow!!!! x,, y-- --- a--> b<-- c-> d<- e<-> f<--> (tm) (pa)",
|
||
)
|
||
|
||
expect(rich).to have_css(
|
||
"p",
|
||
text: "foo ± bar… test??? wow!!! x, y– — a–> b←- c→ d← e←> f←→ ™ ¶",
|
||
)
|
||
end
|
||
|
||
it "supports ---, ***, ___, en-dash+hyphen, em-dash+hyphen to create a horizontal rule" do
|
||
open_composer
|
||
composer.type_content("Hey\n---There\n*** Friend\n___ How\n\u2013-are\n\u2014-you")
|
||
|
||
expect(rich).to have_css("hr", count: 5)
|
||
end
|
||
|
||
it "supports <http://example.com> to create an 'autolink'" do
|
||
open_composer
|
||
composer.type_content("<http://example.com>")
|
||
|
||
expect(rich).to have_css("a", text: "http://example.com")
|
||
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_value("<http://example.com>")
|
||
end
|
||
|
||
it "supports [quote] to create a quote block" do
|
||
open_composer
|
||
composer.type_content("[quote]")
|
||
|
||
expect(rich).to have_css("aside.quote blockquote")
|
||
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_value("[quote]\n\n[/quote]\n\n")
|
||
end
|
||
|
||
it "supports [quote=\"username\"] to create a quote block with attribution" do
|
||
open_composer
|
||
composer.type_content("[quote=\"johndoe\"]")
|
||
|
||
expect(rich).to have_css("aside.quote[data-username='johndoe'] .title", text: "johndoe:")
|
||
expect(rich).to have_css("aside.quote blockquote")
|
||
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_value("[quote=\"johndoe\"]\n\n[/quote]\n\n")
|
||
end
|
||
|
||
it "supports [quote=\"username, post:1, topic:123\"] to create a quote block with full attribution" do
|
||
open_composer
|
||
composer.type_content("[quote=\"johndoe, post:1, topic:123\"]")
|
||
|
||
expect(rich).to have_css(
|
||
"aside.quote[data-username='johndoe'][data-post='1'][data-topic='123'] .title",
|
||
text: "johndoe:",
|
||
)
|
||
expect(rich).to have_css("aside.quote blockquote")
|
||
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_value("[quote=\"johndoe, post:1, topic:123\"]\n\n[/quote]\n\n")
|
||
end
|
||
|
||
it "doesn't trigger quote input rule in the middle of text" do
|
||
open_composer
|
||
composer.type_content("This [quote] should not trigger")
|
||
|
||
expect(rich).to have_no_css("aside.quote")
|
||
expect(rich).to have_content("This [quote] should not trigger")
|
||
end
|
||
end
|
||
|
||
context "with oneboxing" do
|
||
let(:cdp) { PageObjects::CDP.new }
|
||
|
||
before do
|
||
def body(title)
|
||
<<~HTML
|
||
<html>
|
||
<head>
|
||
<title>#{title}</title>
|
||
<meta property="og:title" content="#{title}">
|
||
<meta property="og:description" content="This is an example site">
|
||
</head>
|
||
<body>
|
||
<h1>#{title}</h1>
|
||
<p>This domain is for use in examples.</p>
|
||
</body>
|
||
</html>
|
||
HTML
|
||
end
|
||
|
||
stub_request(:head, %r{https://example\.com.*}).to_return(status: 200)
|
||
stub_request(:get, %r{https://example\.com.*}).to_return(
|
||
status: 200,
|
||
body: body("Example Site 1"),
|
||
)
|
||
|
||
stub_request(:head, %r{https://example2\.com.*}).to_return(status: 200)
|
||
stub_request(:get, %r{https://example2\.com.*}).to_return(
|
||
status: 200,
|
||
body: body("Example Site 2"),
|
||
)
|
||
|
||
stub_request(:head, %r{https://example3\.com.*}).to_return(status: 200)
|
||
stub_request(:get, %r{https://example3\.com.*}).to_return(
|
||
status: 200,
|
||
body: body("Example Site 3"),
|
||
)
|
||
end
|
||
|
||
it "creates an inline onebox for links within text" do
|
||
cdp.allow_clipboard
|
||
open_composer
|
||
composer.type_content("Check out this link ")
|
||
cdp.copy_paste("https://example.com/x")
|
||
composer.type_content(:space)
|
||
|
||
expect(rich).to have_css(
|
||
"a.inline-onebox[href='https://example.com/x']",
|
||
text: "Example Site 1",
|
||
)
|
||
|
||
composer.type_content("in the middle of text")
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_value(
|
||
"Check out this link https://example.com/x in the middle of text",
|
||
)
|
||
end
|
||
|
||
it "creates a full onebox for standalone links" do
|
||
cdp.allow_clipboard
|
||
open_composer
|
||
cdp.copy_paste("https://example.com")
|
||
page.send_keys(:enter)
|
||
|
||
expect(rich).to have_css("div.onebox-wrapper[data-onebox-src='https://example.com']")
|
||
expect(rich).to have_content("Example Site 1")
|
||
expect(rich).to have_content("This is an example site")
|
||
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_value("https://example.com\n\n")
|
||
end
|
||
|
||
it "creates an inline onebox for links that are part of a paragraph" do
|
||
cdp.allow_clipboard
|
||
open_composer
|
||
composer.type_content("Some text ")
|
||
cdp.copy_paste("https://example.com/x")
|
||
composer.type_content(:space)
|
||
|
||
expect(rich).to have_no_css("div.onebox-wrapper")
|
||
expect(rich).to have_css("a.inline-onebox", text: "Example Site 1")
|
||
|
||
composer.type_content("more text")
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_value("Some text https://example.com/x more text")
|
||
end
|
||
|
||
it "does not create oneboxes inside code blocks" do
|
||
cdp.allow_clipboard
|
||
open_composer
|
||
composer.type_content("```")
|
||
cdp.copy_paste("https://example.com")
|
||
|
||
expect(rich).to have_css("pre code")
|
||
expect(rich).to have_no_css("div.onebox-wrapper")
|
||
expect(rich).to have_no_css("a.inline-onebox")
|
||
expect(rich).to have_content("https://example.com")
|
||
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_value("```\nhttps://example.com\n```")
|
||
end
|
||
|
||
it "creates oneboxes for mixed content" do
|
||
cdp.allow_clipboard
|
||
open_composer
|
||
markdown = <<~MARKDOWN
|
||
https://example.com
|
||
|
||
Check this https://example.com/x and see if it fits you
|
||
|
||
https://example2.com
|
||
|
||
An inline to https://example2.com/x with text around it
|
||
|
||
https://example3.com
|
||
|
||
Another one for https://example3.com/x then
|
||
|
||
https://example.com
|
||
|
||
Phew, repeating https://example.com/x now
|
||
|
||
https://example2.com
|
||
|
||
And some text again https://example2.com/x
|
||
|
||
https://example3.com/x
|
||
|
||
Ok, that is it https://example3.com/x
|
||
After a hard break
|
||
MARKDOWN
|
||
cdp.copy_paste(markdown)
|
||
|
||
expect(rich).to have_css("a.inline-onebox", count: 6)
|
||
expect(rich).to have_css(
|
||
"a.inline-onebox[href='https://example.com/x']",
|
||
text: "Example Site 1",
|
||
)
|
||
expect(rich).to have_css(
|
||
"a.inline-onebox[href='https://example2.com/x']",
|
||
text: "Example Site 2",
|
||
)
|
||
expect(rich).to have_css(
|
||
"a.inline-onebox[href='https://example3.com/x']",
|
||
text: "Example Site 3",
|
||
)
|
||
|
||
expect(rich).to have_css("div.onebox-wrapper", count: 6)
|
||
expect(rich).to have_css("div.onebox-wrapper[data-onebox-src='https://example.com']")
|
||
expect(rich).to have_css("div.onebox-wrapper[data-onebox-src='https://example2.com']")
|
||
expect(rich).to have_css("div.onebox-wrapper[data-onebox-src='https://example3.com']")
|
||
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_value(markdown[0..-2])
|
||
end
|
||
|
||
xit "creates inline oneboxes for repeated links in different paste events" do
|
||
cdp.allow_clipboard
|
||
open_composer
|
||
composer.type_content("Hey ")
|
||
cdp.copy_paste("https://example.com/x")
|
||
composer.type_content(:space).type_content("and").type_content(:space)
|
||
cdp.paste
|
||
composer.type_content(:enter)
|
||
|
||
expect(rich).to have_css(
|
||
"a.inline-onebox[href='https://example.com/x']",
|
||
text: "Example Site 1",
|
||
count: 2,
|
||
)
|
||
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_value("Hey https://example.com/x and https://example.com/x")
|
||
end
|
||
end
|
||
|
||
context "with keymap" do
|
||
PLATFORM_KEY_MODIFIER = SystemHelpers::PLATFORM_KEY_MODIFIER
|
||
it "supports Ctrl + B to create a bold text" do
|
||
open_composer
|
||
composer.type_content([PLATFORM_KEY_MODIFIER, "b"])
|
||
composer.type_content("This is bold")
|
||
|
||
expect(rich).to have_css("strong", text: "This is bold")
|
||
end
|
||
|
||
it "supports Ctrl + I to create an italic text" do
|
||
open_composer
|
||
composer.type_content([PLATFORM_KEY_MODIFIER, "i"])
|
||
composer.type_content("This is italic")
|
||
|
||
expect(rich).to have_css("em", text: "This is italic")
|
||
end
|
||
|
||
it "supports Ctrl + K to create a link" do
|
||
hyperlink_modal = PageObjects::Modals::Base.new
|
||
open_composer
|
||
page.send_keys([PLATFORM_KEY_MODIFIER, "k"])
|
||
expect(hyperlink_modal).to be_open
|
||
expect(hyperlink_modal.header).to have_content(I18n.t("js.composer.link_dialog_title"))
|
||
page.send_keys("https://www.example.com")
|
||
page.send_keys(:tab)
|
||
page.send_keys("This is a link")
|
||
page.send_keys(:enter)
|
||
|
||
expect(rich).to have_css("a", text: "This is a link")
|
||
end
|
||
|
||
it "supports Ctrl + Shift + 7 to create an ordered list" do
|
||
open_composer
|
||
composer.type_content("Item 1")
|
||
composer.send_keys([PLATFORM_KEY_MODIFIER, :shift, "7"])
|
||
|
||
expect(rich).to have_css("ol li", text: "Item 1")
|
||
end
|
||
|
||
it "supports Ctrl + Shift + 8 to create a bullet list" do
|
||
open_composer
|
||
composer.type_content("Item 1")
|
||
composer.send_keys([PLATFORM_KEY_MODIFIER, :shift, "8"])
|
||
|
||
expect(rich).to have_css("ul li", text: "Item 1")
|
||
end
|
||
|
||
it "supports Ctrl + Shift + 9 to create a blockquote" do
|
||
open_composer
|
||
composer.type_content("This is a blockquote")
|
||
composer.send_keys([PLATFORM_KEY_MODIFIER, :shift, "9"])
|
||
|
||
expect(rich).to have_css("blockquote", text: "This is a blockquote")
|
||
end
|
||
|
||
it "supports Ctrl + Shift + 1-4 for headings, 0 for reset" do
|
||
open_composer
|
||
(1..4).each do |i|
|
||
composer.type_content("\nHeading #{i}")
|
||
composer.send_keys([PLATFORM_KEY_MODIFIER, :alt, i.to_s])
|
||
|
||
expect(rich).to have_css("h#{i}", text: "Heading #{i}")
|
||
end
|
||
|
||
composer.send_keys([PLATFORM_KEY_MODIFIER, :alt, "0"])
|
||
expect(rich).not_to have_css("h4")
|
||
end
|
||
|
||
it "supports Ctrl + Z and Ctrl + Shift + Z to undo and redo" do
|
||
open_composer
|
||
cdp.copy_paste("This is a test")
|
||
composer.send_keys([PLATFORM_KEY_MODIFIER, "z"])
|
||
|
||
expect(rich).not_to have_css("p", text: "This is a test")
|
||
|
||
composer.send_keys([PLATFORM_KEY_MODIFIER, :shift, "z"])
|
||
|
||
expect(rich).to have_css("p", text: "This is a test")
|
||
end
|
||
|
||
it "supports Ctrl + Shift + _ to create a horizontal rule" do
|
||
open_composer
|
||
composer.type_content("This is a test")
|
||
composer.send_keys([PLATFORM_KEY_MODIFIER, :shift, "_"])
|
||
|
||
expect(rich).to have_css("hr")
|
||
end
|
||
|
||
it "creates hard break when pressing Enter after double space at end of line" do
|
||
open_composer
|
||
composer.type_content("Line with double space ")
|
||
composer.send_keys(:enter)
|
||
composer.type_content("Next line")
|
||
|
||
composer.toggle_rich_editor
|
||
expect(composer).to have_value("Line with double space\nNext line")
|
||
end
|
||
|
||
it "supports Backspace to reset a heading" do
|
||
open_composer
|
||
composer.type_content("# With text")
|
||
|
||
expect(rich).to have_css("h1", text: "With text")
|
||
|
||
composer.send_keys(:home)
|
||
wait_for_timeout
|
||
composer.send_keys(:backspace)
|
||
|
||
expect(rich).to have_css("p", text: "With text")
|
||
end
|
||
|
||
it "supports Backspace to reset a code_block" do
|
||
open_composer
|
||
composer.type_content("```code block")
|
||
composer.send_keys(:home)
|
||
wait_for_timeout
|
||
composer.send_keys(:backspace)
|
||
|
||
expect(rich).to have_css("p", text: "code block")
|
||
end
|
||
|
||
it "doesn't add a new list item when backspacing from below a list" do
|
||
open_composer
|
||
composer.type_content("1. Item 1\nItem 2")
|
||
composer.send_keys(:down)
|
||
composer.type_content("Item 3")
|
||
composer.send_keys(:home)
|
||
composer.send_keys(:backspace)
|
||
|
||
expect(rich).to have_css("ol li", text: "Item 1")
|
||
expect(rich).to have_css("ol li", text: "Item 2Item 3")
|
||
end
|
||
|
||
it "supports Ctrl + M to toggle between rich and markdown editors" do
|
||
open_composer
|
||
|
||
composer.type_content("> This is a test")
|
||
|
||
expect(composer).to have_value(nil)
|
||
expect(rich).to have_css("blockquote", text: "This is a test")
|
||
|
||
composer.send_keys([:control, "m"])
|
||
|
||
expect(composer).to have_value("> This is a test")
|
||
expect(composer).to have_no_rich_editor
|
||
|
||
composer.send_keys([:control, "m"])
|
||
|
||
expect(composer).to have_value(nil)
|
||
expect(rich).to have_css("blockquote", text: "This is a test")
|
||
end
|
||
|
||
it "adds a new paragraph when ENTER is pressed after an image" do
|
||
open_composer
|
||
composer.type_content("")
|
||
composer.send_keys(:right, :enter)
|
||
composer.type_content("This is a test")
|
||
|
||
composer.toggle_rich_editor
|
||
expect(composer).to have_value("\n\n\nThis is a test")
|
||
end
|
||
end
|
||
|
||
describe "pasting content" do
|
||
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']",
|
||
)
|
||
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(
|
||
'',
|
||
)
|
||
end
|
||
|
||
xit "ignores text/html content if Files are present" do
|
||
open_composer
|
||
paste_and_click_image
|
||
|
||
expect(rich).to have_css("img[data-orig-src]", count: 1)
|
||
|
||
composer.focus # making sure the toggle click won't be captured as a double click
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_value("")
|
||
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 "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
|
||
end
|
||
|
||
describe "toolbar state updates" do
|
||
it "updates the toolbar state following the cursor position" do
|
||
open_composer
|
||
|
||
expect(page).to have_css(".toolbar__button.bold.--active", count: 0)
|
||
expect(page).to have_css(".toolbar__button.italic.--active", count: 0)
|
||
expect(page).to have_css(".toolbar__button.heading.--active", count: 0)
|
||
expect(page).to have_css(".toolbar__button.link.--active", count: 0)
|
||
expect(page).to have_css(".toolbar__button.bullet.--active", count: 0)
|
||
expect(page).to have_css(".toolbar__button.list.--active", count: 0)
|
||
expect(page).to have_css(".toolbar__button.code.--active", count: 0)
|
||
expect(page).to have_css(".toolbar__button.blockquote.--active", count: 0)
|
||
|
||
composer.type_content("> - ` [***many styles***](https://example.com)`")
|
||
composer.send_keys(:left, :left)
|
||
|
||
expect(page).to have_css(".toolbar__button.bold.--active", count: 1)
|
||
expect(page).to have_css(".toolbar__button.italic.--active", count: 1)
|
||
expect(page).to have_css(".toolbar__button.link.--active", count: 1)
|
||
expect(page).to have_css(".toolbar__button.bullet.--active", count: 1)
|
||
expect(page).to have_css(".toolbar__button.list.--active", count: 0)
|
||
expect(page).to have_css(".toolbar__button.code.--active", count: 1)
|
||
expect(page).to have_css(".toolbar__button.blockquote.--active", count: 1)
|
||
|
||
page.find(".toolbar__button.bullet").click
|
||
page.find(".toolbar__button.list").click
|
||
|
||
expect(page).to have_css(".toolbar__button.list.--active", count: 1)
|
||
expect(page).to have_css(".toolbar__button.bullet.--active", count: 0)
|
||
end
|
||
end
|
||
|
||
describe "trailing paragraph" do
|
||
it "ensures there is always a trailing paragraph" do
|
||
open_composer
|
||
|
||
expect(rich).to have_css("p", count: 1)
|
||
composer.type_content("This is a test")
|
||
|
||
expect(rich).to have_css("p", count: 1)
|
||
expect(rich).to have_css("p", text: "This is a test", count: 1)
|
||
|
||
composer.send_keys([PLATFORM_KEY_MODIFIER, :shift, "_"]) # Insert a horizontal rule
|
||
expect(rich).to have_css("hr", count: 1)
|
||
expect(rich).to have_css("p", count: 2) # New paragraph inserted after the ruler
|
||
end
|
||
end
|
||
|
||
describe "auto-linking/unlinking while typing" do
|
||
it "auto-links non-protocol URLs and removes the link when no longer a URL" do
|
||
open_composer
|
||
|
||
composer.type_content("www.example.com and also mid-paragraph www.example2.com")
|
||
|
||
expect(rich).to have_css("a", text: "www.example.com")
|
||
expect(rich).to have_css("a", text: "www.example2.com")
|
||
expect(rich).to have_css("a", count: 2)
|
||
|
||
composer.send_keys(:backspace)
|
||
composer.send_keys(:backspace)
|
||
|
||
expect(rich).to have_css("a", count: 1)
|
||
|
||
composer.type_content("om")
|
||
|
||
expect(rich).to have_css("a", text: "www.example2.com")
|
||
end
|
||
|
||
it "auto-links protocol URLs" do
|
||
open_composer
|
||
|
||
composer.type_content("https://example.com")
|
||
|
||
expect(rich).to have_css("a", text: "https://example.com")
|
||
|
||
composer.send_keys(:backspace)
|
||
composer.send_keys(:backspace)
|
||
|
||
expect(rich).to have_css("a", text: "https://example.c")
|
||
end
|
||
|
||
it "doesn't auto-link immediately following a `" do
|
||
open_composer
|
||
|
||
composer.type_content("`https://example.com`")
|
||
|
||
expect(rich).to have_css("code", text: "https://example.com")
|
||
expect(rich).to have_no_css("a", text: "https://example.com")
|
||
end
|
||
|
||
it "doesn't auto-link within code marks" do
|
||
open_composer
|
||
|
||
composer.type_content("`code mark`")
|
||
composer.send_keys(:left)
|
||
|
||
composer.type_content(" https://example.com")
|
||
|
||
expect(rich).to have_css("code", text: "code mark https://example.com")
|
||
expect(rich).to have_no_css("a", text: "https://example.com")
|
||
end
|
||
|
||
it "doesn't continue a <https://url> markup='autolink'" do
|
||
open_composer
|
||
|
||
composer.type_content("<https://example.com>.de")
|
||
|
||
expect(rich).to have_css("a", text: "https://example.com")
|
||
expect(rich).to have_no_css("a", text: "https://example.com.de")
|
||
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_value("<https://example.com>.de")
|
||
end
|
||
end
|
||
|
||
describe "uploads" do
|
||
it "handles uploads and disables the editor toggle while uploading" do
|
||
open_composer
|
||
|
||
file_path = file_from_fixtures("logo.png", "images").path
|
||
cdp.with_slow_upload do
|
||
attach_file("file-uploader", file_path, make_visible: true)
|
||
expect(composer).to have_in_progress_uploads
|
||
expect(composer.editor_toggle_switch).to be_disabled
|
||
end
|
||
|
||
expect(composer).to have_no_in_progress_uploads
|
||
expect(rich).to have_css("img:not(.ProseMirror-separator)", count: 1)
|
||
end
|
||
end
|
||
|
||
describe "code marks with fake cursor" do
|
||
it "allows typing after a code mark with/without the mark" do
|
||
open_composer
|
||
|
||
composer.type_content("This is ~~SPARTA!~~ `code!`.")
|
||
|
||
expect(rich).to have_css("code", text: "code!")
|
||
|
||
# within the code mark
|
||
composer.send_keys(:backspace)
|
||
composer.send_keys(:backspace)
|
||
composer.type_content("!")
|
||
|
||
expect(rich).to have_css("code", text: "code!")
|
||
|
||
# after the code mark
|
||
composer.send_keys(:right)
|
||
composer.type_content(".")
|
||
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_value("This is ~~SPARTA!~~ `code!`.")
|
||
end
|
||
|
||
xit "allows typing before a code mark with/without the mark" do
|
||
open_composer
|
||
|
||
composer.type_content("`code mark`")
|
||
|
||
expect(rich).to have_css("code", text: "code mark")
|
||
|
||
# before the code mark
|
||
composer.send_keys(:home)
|
||
composer.send_keys(:left)
|
||
composer.type_content("..")
|
||
|
||
# within the code mark
|
||
composer.send_keys(:right)
|
||
composer.type_content("!!")
|
||
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_value("..`!!code mark`")
|
||
end
|
||
end
|
||
|
||
describe "emojis" do
|
||
it "has the only-emoji class if 1-3 emojis are 'alone'" do
|
||
open_composer
|
||
|
||
composer.type_content("> :smile: ")
|
||
|
||
expect(rich).to have_css(".only-emoji", count: 1)
|
||
|
||
composer.type_content(":P ")
|
||
|
||
expect(rich).to have_css(".only-emoji", count: 2)
|
||
|
||
composer.type_content(":D ")
|
||
|
||
expect(rich).to have_css(".only-emoji", count: 3)
|
||
|
||
composer.type_content("Hey!")
|
||
|
||
expect(rich).to have_no_css(".only-emoji")
|
||
end
|
||
|
||
it "preserves formatting marks when replacing text with emojis using :code: pattern" do
|
||
open_composer
|
||
|
||
composer.type_content("**bold :smile:**")
|
||
|
||
expect(rich).to have_css("strong img.emoji")
|
||
expect(rich).to have_css("strong", text: "bold")
|
||
|
||
composer.toggle_rich_editor
|
||
expect(composer).to have_value("**bold :smile:**")
|
||
end
|
||
|
||
it "preserves formatting marks when replacing text with emojis using text shortcuts" do
|
||
open_composer
|
||
|
||
composer.type_content("*italics :) *")
|
||
|
||
expect(rich).to have_css("em img.emoji")
|
||
expect(rich).to have_css("em", text: "italics")
|
||
|
||
composer.toggle_rich_editor
|
||
expect(composer).to have_value("*italics :slight_smile:* ")
|
||
end
|
||
|
||
it "preserves link marks when replacing text with emojis" do
|
||
open_composer
|
||
|
||
composer.type_content("[link text :heart:](https://example.com)")
|
||
|
||
expect(rich).to have_css("a img.emoji")
|
||
expect(rich).to have_css("a", text: "link text")
|
||
|
||
composer.toggle_rich_editor
|
||
expect(composer).to have_value("[link text :heart:](https://example.com)")
|
||
end
|
||
end
|
||
|
||
describe "with mentions" do
|
||
fab!(:post)
|
||
fab!(:topic) { post.topic }
|
||
fab!(:mixed_case_user) { Fabricate(:user, username: "TestUser_123") }
|
||
fab!(:mixed_case_group) do
|
||
Fabricate(:group, name: "TestGroup_ABC", mentionable_level: Group::ALIAS_LEVELS[:everyone])
|
||
end
|
||
|
||
before do
|
||
Draft.set(
|
||
current_user,
|
||
topic.draft_key,
|
||
0,
|
||
{ reply: "hey @#{current_user.username} and @unknown - how are you?" }.to_json,
|
||
)
|
||
end
|
||
|
||
it "validates manually typed mentions" do
|
||
open_composer
|
||
|
||
composer.type_content("Hey @#{current_user.username} ")
|
||
|
||
expect(rich).to have_css("a.mention", text: current_user.username)
|
||
|
||
composer.type_content("and @invalid_user - how are you?")
|
||
|
||
expect(rich).to have_no_css("a.mention", text: "@invalid_user")
|
||
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_value(
|
||
"Hey @#{current_user.username} and @invalid_user - how are you?",
|
||
)
|
||
end
|
||
|
||
it "validates mentions in drafts" do
|
||
page.visit("/t/#{topic.id}")
|
||
|
||
expect(composer).to be_opened
|
||
|
||
expect(rich).to have_css("a.mention", text: current_user.username)
|
||
expect(rich).to have_no_css("a.mention", text: "@unknown")
|
||
end
|
||
|
||
it "validates mentions case-insensitively" do
|
||
open_composer
|
||
|
||
composer.type_content("Hey @testuser_123 and @TESTUSER_123 ")
|
||
|
||
expect(rich).to have_css("a.mention", text: "testuser_123")
|
||
expect(rich).to have_css("a.mention", text: "TESTUSER_123")
|
||
|
||
composer.type_content("and @InvalidUser ")
|
||
|
||
expect(rich).to have_no_css("a.mention", text: "@InvalidUser")
|
||
end
|
||
|
||
it "validates group mentions case-insensitively" do
|
||
open_composer
|
||
|
||
composer.type_content("Hey @testgroup_abc and @TESTGROUP_ABC ")
|
||
|
||
expect(rich).to have_css("a.mention", text: "testgroup_abc")
|
||
expect(rich).to have_css("a.mention", text: "TESTGROUP_ABC")
|
||
|
||
composer.type_content("and @InvalidGroup ")
|
||
|
||
expect(rich).to have_no_css("a.mention", text: "@InvalidGroup")
|
||
end
|
||
|
||
describe "with unicode usernames" do
|
||
fab!(:category)
|
||
|
||
before do
|
||
SiteSetting.external_system_avatars_enabled = true
|
||
SiteSetting.external_system_avatars_url =
|
||
"/letter_avatar_proxy/v4/letter/{first_letter}/{color}/{size}.png"
|
||
SiteSetting.unicode_usernames = true
|
||
end
|
||
|
||
it "renders unicode mentions as nodes" do
|
||
unicode_user = Fabricate(:unicode_user)
|
||
|
||
open_composer
|
||
|
||
composer.type_content("Hey @#{unicode_user.username} - how are you?")
|
||
|
||
expect(rich).to have_css("a.mention", text: unicode_user.username)
|
||
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_value("Hey @#{unicode_user.username} - how are you?")
|
||
end
|
||
end
|
||
end
|
||
|
||
describe "link toolbar" do
|
||
let(:upsert_hyperlink_modal) { PageObjects::Modals::UpsertHyperlink.new }
|
||
|
||
it "shows link toolbar when cursor is on a link" do
|
||
open_composer
|
||
|
||
composer.type_content("[Example](https://example.com)")
|
||
composer.send_keys(:left, :left, :left)
|
||
|
||
expect(page).to have_css("[data-identifier='composer-link-toolbar']")
|
||
expect(page).to have_css("button.composer-link-toolbar__edit")
|
||
expect(page).to have_css("button.composer-link-toolbar__copy")
|
||
expect(page).to have_css("a.composer-link-toolbar__visit", text: "example.com")
|
||
end
|
||
|
||
it "allows editing a link via toolbar" do
|
||
cdp.allow_clipboard
|
||
open_composer
|
||
|
||
composer.type_content("[Example](https://example.com)")
|
||
composer.send_keys(:left, :left, :left)
|
||
|
||
# Use Tab to navigate to the toolbar and Enter to activate edit
|
||
composer.send_keys(:tab, :enter)
|
||
|
||
expect(upsert_hyperlink_modal).to be_open
|
||
|
||
expect(upsert_hyperlink_modal.link_text_value).to eq("Example")
|
||
expect(upsert_hyperlink_modal.link_url_value).to eq("https://example.com")
|
||
|
||
upsert_hyperlink_modal.fill_in_link_text("Updated Example")
|
||
upsert_hyperlink_modal.fill_in_link_url("https://updated-example.com")
|
||
upsert_hyperlink_modal.send_enter_link_text
|
||
|
||
expect(rich).to have_css("a[href='https://updated-example.com']", text: "Updated Example")
|
||
|
||
composer.toggle_rich_editor
|
||
expect(composer).to have_value("[Updated Example](https://updated-example.com)")
|
||
end
|
||
|
||
it "escapes URL when editing link via modal" do
|
||
cdp.allow_clipboard
|
||
open_composer
|
||
|
||
composer.type_content("[Example](https://example.com)")
|
||
composer.send_keys(:left, :left, :left)
|
||
|
||
# Use Tab to navigate to the toolbar and Enter to activate edit
|
||
composer.send_keys(:tab, :enter)
|
||
|
||
expect(upsert_hyperlink_modal).to be_open
|
||
|
||
expect(upsert_hyperlink_modal.link_text_value).to eq("Example")
|
||
expect(upsert_hyperlink_modal.link_url_value).to eq("https://example.com")
|
||
|
||
upsert_hyperlink_modal.fill_in_link_url("https://updated-example.com?query=with space")
|
||
upsert_hyperlink_modal.click_primary_button
|
||
|
||
expect(rich).to have_css(
|
||
"a[href='https://updated-example.com?query=with%20space']",
|
||
text: "Example",
|
||
)
|
||
end
|
||
|
||
it "allows copying a link URL via toolbar" do
|
||
cdp.allow_clipboard
|
||
open_composer
|
||
|
||
composer.type_content("[Example](https://example.com)")
|
||
composer.send_keys(:left, :left, :left)
|
||
|
||
find("button.composer-link-toolbar__copy").click
|
||
|
||
expect(page).to have_content(I18n.t("js.composer.link_toolbar.link_copied"))
|
||
end
|
||
|
||
it "allows unlinking a link via toolbar when markup is not auto or linkify" do
|
||
open_composer
|
||
|
||
composer.type_content("[Manual Link](https://example.com)")
|
||
|
||
find("button.composer-link-toolbar__unlink").click
|
||
|
||
expect(rich).to have_no_css("a")
|
||
expect(rich).to have_content("Manual Link")
|
||
|
||
composer.toggle_rich_editor
|
||
expect(composer).to have_value("Manual Link")
|
||
end
|
||
|
||
it "doesn't show unlink button for auto-detected links" do
|
||
open_composer
|
||
|
||
composer.type_content("<https://example.com>")
|
||
|
||
expect(page).to have_css("[data-identifier='composer-link-toolbar']")
|
||
expect(page).to have_no_css("button.composer-link-toolbar__unlink")
|
||
expect(page).to have_css("a.composer-link-toolbar__visit", text: "")
|
||
end
|
||
|
||
it "doesn't show unlink button for auto-linkified URLs" do
|
||
open_composer
|
||
|
||
composer.type_content("https://example.com")
|
||
|
||
expect(page).to have_css("[data-identifier='composer-link-toolbar']")
|
||
expect(page).to have_no_css("button.composer-link-toolbar__unlink")
|
||
expect(page).to have_css("a.composer-link-toolbar__visit", text: "")
|
||
end
|
||
|
||
it "shows visit button for valid URLs" do
|
||
open_composer
|
||
|
||
composer.type_content("[Example](https://example.com)")
|
||
|
||
expect(page).to have_css(
|
||
"a.composer-link-toolbar__visit[href='https://example.com']",
|
||
text: "example.com",
|
||
)
|
||
end
|
||
|
||
it "strips base URL from internal links in toolbar display" do
|
||
open_composer
|
||
|
||
internal_link = "#{Discourse.base_url}/t/some-topic/123"
|
||
|
||
composer.type_content("[Internal Link](#{internal_link})")
|
||
composer.send_keys(:left, :left, :left)
|
||
|
||
expect(page).to have_css("[data-identifier='composer-link-toolbar']")
|
||
expect(page).to have_css(
|
||
"a.composer-link-toolbar__visit[href='#{internal_link}']",
|
||
text: "/t/some-topic/123",
|
||
)
|
||
end
|
||
|
||
it "doesn't show visit button for invalid URLs" do
|
||
open_composer
|
||
|
||
composer.type_content("[Example](not-a-url)")
|
||
|
||
expect(page).to have_css("[data-identifier='composer-link-toolbar']")
|
||
expect(page).to have_no_css("a.composer-link-toolbar__visit")
|
||
expect(page).to have_no_css(".composer-link-toolbar__divider")
|
||
end
|
||
|
||
it "closes toolbar when cursor moves outside link" do
|
||
open_composer
|
||
|
||
composer.type_content("Text before [Example](https://example.com),")
|
||
|
||
composer.send_keys(:left)
|
||
|
||
expect(page).to have_css("[data-identifier='composer-link-toolbar']")
|
||
expect(page).to have_css("a.composer-link-toolbar__visit", text: "example.com")
|
||
|
||
composer.send_keys(:right)
|
||
|
||
expect(page).to have_no_css("[data-identifier='composer-link-toolbar']")
|
||
end
|
||
|
||
it "preserves emojis when editing a link via toolbar" do
|
||
open_composer
|
||
|
||
composer.type_content("[Party :tada: Time](https://example.com)")
|
||
composer.send_keys(:left, :left, :left)
|
||
|
||
# Use Tab to navigate to the toolbar and Enter to activate edit
|
||
composer.send_keys(:tab, :enter)
|
||
|
||
expect(upsert_hyperlink_modal).to be_open
|
||
|
||
expect(upsert_hyperlink_modal.link_text_value).to eq("Party :tada: Time")
|
||
expect(upsert_hyperlink_modal.link_url_value).to eq("https://example.com")
|
||
|
||
upsert_hyperlink_modal.fill_in_link_text("Updated :tada: Party")
|
||
upsert_hyperlink_modal.fill_in_link_url("https://updated-party.com")
|
||
upsert_hyperlink_modal.click_primary_button
|
||
|
||
expect(rich).to have_css("a[href='https://updated-party.com']")
|
||
expect(rich).to have_css("a img[title=':tada:'], a img[alt=':tada:']")
|
||
|
||
composer.toggle_rich_editor
|
||
expect(composer).to have_value("[Updated :tada: Party](https://updated-party.com)")
|
||
end
|
||
|
||
it "preserves bold and italic formatting when editing a link via toolbar" do
|
||
open_composer
|
||
|
||
composer.type_content("[**Bold** and *italic* text](https://example.com)")
|
||
composer.send_keys(:left, :left, :left)
|
||
|
||
# Use Tab to navigate to the toolbar and Enter to activate edit
|
||
composer.send_keys(:tab, :enter)
|
||
|
||
expect(upsert_hyperlink_modal).to be_open
|
||
|
||
expect(upsert_hyperlink_modal.link_text_value).to eq("**Bold** and *italic* text")
|
||
expect(upsert_hyperlink_modal.link_url_value).to eq("https://example.com")
|
||
|
||
upsert_hyperlink_modal.fill_in_link_text("Updated **bold** and *italic* content")
|
||
upsert_hyperlink_modal.fill_in_link_url("https://updated-example.com")
|
||
upsert_hyperlink_modal.click_primary_button
|
||
|
||
expect(rich).to have_css("a[href='https://updated-example.com']")
|
||
expect(rich).to have_css("strong a", text: "bold")
|
||
expect(rich).to have_css("em a", text: "italic")
|
||
|
||
composer.toggle_rich_editor
|
||
expect(composer).to have_value(
|
||
"[Updated **bold** and *italic* content](https://updated-example.com)",
|
||
)
|
||
end
|
||
end
|
||
|
||
describe "image toolbar" do
|
||
it "allows scaling image down and up via toolbar" do
|
||
open_composer
|
||
paste_and_click_image
|
||
|
||
find(".composer-image-toolbar__zoom-out").click
|
||
|
||
expect(rich).to have_selector(".composer-image-node img[data-scale='75']")
|
||
|
||
find(".composer-image-toolbar__zoom-out").click
|
||
|
||
expect(rich).to have_selector(".composer-image-node img[data-scale='50']")
|
||
|
||
find(".composer-image-toolbar__zoom-in").click
|
||
|
||
expect(rich).to have_selector(".composer-image-node img[data-scale='75']")
|
||
|
||
find(".composer-image-toolbar__zoom-in").click
|
||
|
||
expect(rich).to have_selector(".composer-image-node img[data-scale='100']")
|
||
end
|
||
|
||
it "allows removing image via toolbar" do
|
||
open_composer
|
||
composer.type_content("Before")
|
||
paste_and_click_image
|
||
|
||
find(".composer-image-toolbar__trash").click
|
||
|
||
expect(rich).to have_no_css(".composer-image-node img")
|
||
expect(rich).to have_content("Before")
|
||
end
|
||
|
||
it "hides toolbar when clicking outside image" do
|
||
open_composer
|
||
paste_and_click_image
|
||
|
||
expect(page).to have_css("[data-identifier='composer-image-toolbar']")
|
||
|
||
rich.find("p").click
|
||
|
||
expect(page).to have_no_css("[data-identifier='composer-image-toolbar']")
|
||
end
|
||
|
||
it "sets width and height attributes when scaling external images" do
|
||
open_composer
|
||
|
||
image = Fabricate(:image_upload)
|
||
|
||
composer.type_content("")
|
||
|
||
find(".composer-image-node img").click
|
||
|
||
expect(rich).to have_no_css(".composer-image-node img[width]")
|
||
expect(rich).to have_no_css(".composer-image-node img[height]")
|
||
|
||
find(".composer-image-toolbar__zoom-out").click
|
||
|
||
expect(rich).to have_css(".composer-image-node img[width]")
|
||
expect(rich).to have_css(".composer-image-node img[height]")
|
||
end
|
||
end
|
||
|
||
describe "image URL resolution" do
|
||
it "resolves upload URLs and displays images correctly" do
|
||
open_composer
|
||
cdp.allow_clipboard
|
||
|
||
upload1 = Fabricate(:upload)
|
||
upload2 = Fabricate(:upload)
|
||
|
||
short_url1 = "upload://#{Upload.base62_sha1(upload1.sha1)}"
|
||
short_url2 = "upload://#{Upload.base62_sha1(upload2.sha1)}"
|
||
|
||
page.execute_script(<<~JS)
|
||
window.urlLookupRequests = 0;
|
||
|
||
const originalXHROpen = window.XMLHttpRequest.prototype.open;
|
||
window.XMLHttpRequest.prototype.open = function(method, url) {
|
||
if (url.toString().endsWith('/uploads/lookup-urls')) {
|
||
window.urlLookupRequests++;
|
||
}
|
||
return originalXHROpen.apply(this, arguments);
|
||
};
|
||
JS
|
||
|
||
markdown = "\n\n"
|
||
cdp.copy_paste(markdown)
|
||
|
||
expect(page).to have_css("img[src='#{upload1.url}'][data-orig-src='#{short_url1}']")
|
||
expect(page).to have_css("img[src='#{upload2.url}'][data-orig-src='#{short_url2}']")
|
||
|
||
# loaded in a single api call
|
||
initial_request_count = page.evaluate_script("window.urlLookupRequests")
|
||
expect(initial_request_count).to eq(1)
|
||
|
||
composer.toggle_rich_editor
|
||
composer.toggle_rich_editor
|
||
|
||
expect(page).to have_css("img[src='#{upload1.url}'][data-orig-src='#{short_url1}']")
|
||
expect(page).to have_css("img[src='#{upload2.url}'][data-orig-src='#{short_url2}']")
|
||
|
||
# loaded from cache, no new request
|
||
final_request_count = page.evaluate_script("window.urlLookupRequests")
|
||
expect(final_request_count).to eq(initial_request_count)
|
||
end
|
||
end
|
||
|
||
describe "image alt text display and editing" do
|
||
it "shows alt text input when image is selected" do
|
||
open_composer
|
||
paste_and_click_image
|
||
|
||
expect(page).to have_css("[data-identifier='composer-image-alt-text']")
|
||
expect(page).to have_css(".image-alt-text-input__display")
|
||
end
|
||
|
||
it "allows editing alt text by clicking on display" do
|
||
open_composer
|
||
paste_and_click_image
|
||
|
||
find(".image-alt-text-input__display").click
|
||
|
||
expect(page).to have_css(".image-alt-text-input.--expanded")
|
||
expect(page).to have_css(".image-alt-text-input__field")
|
||
|
||
find(".image-alt-text-input__field").fill_in(with: "updated alt text")
|
||
find(".image-alt-text-input__field").send_keys(:enter)
|
||
|
||
expect(rich.find(".composer-image-node img")["alt"]).to eq("updated alt text")
|
||
end
|
||
|
||
it "saves alt text when leaving the input field" do
|
||
open_composer
|
||
paste_and_click_image
|
||
|
||
find(".image-alt-text-input__display").click
|
||
find(".image-alt-text-input__field").fill_in(with: "new alt text")
|
||
|
||
rich.find("p").click
|
||
|
||
expect(rich.find(".composer-image-node img")["alt"]).to eq("new alt text")
|
||
end
|
||
|
||
it "displays the placeholder if alt text is empty" do
|
||
open_composer
|
||
paste_and_click_image
|
||
|
||
expect(page).to have_css(".image-alt-text-input__display", text: "image")
|
||
|
||
find(".image-alt-text-input__display").click
|
||
find(".image-alt-text-input__field").fill_in(with: "")
|
||
find(".image-alt-text-input__field").send_keys(:enter)
|
||
|
||
expect(page).to have_css(
|
||
".image-alt-text-input__display",
|
||
text: I18n.t("js.composer.image_alt_text.title"),
|
||
)
|
||
end
|
||
end
|
||
|
||
describe "heading toolbar" do
|
||
it "updates toolbar active state and icon based on current heading level" do
|
||
open_composer
|
||
|
||
composer.type_content("## This is a test\n#### And this is another test")
|
||
expect(page).to have_css(".toolbar__button.heading.--active", count: 1)
|
||
expect(find(".toolbar__button.heading")).to have_css(".d-icon-discourse-h4")
|
||
|
||
composer.send_keys(:up)
|
||
expect(page).to have_css(".toolbar__button.heading.--active", count: 1)
|
||
expect(find(".toolbar__button.heading")).to have_css(".d-icon-discourse-h2")
|
||
|
||
composer.select_all
|
||
expect(page).to have_no_css(".toolbar__button.heading.--active")
|
||
expect(find(".toolbar__button.heading")).to have_css(".d-icon-discourse-text")
|
||
end
|
||
|
||
it "puts a check next to current heading level in toolbar dropdown, or no check if multiple formats are selected" do
|
||
open_composer
|
||
|
||
composer.type_content("## This is a test\n#### And this is another test")
|
||
|
||
heading_menu = composer.heading_menu
|
||
heading_menu.expand
|
||
expect(heading_menu.option("[data-name='heading-4']")).to have_css(".d-icon-check")
|
||
heading_menu.collapse
|
||
|
||
composer.select_range_rich_editor(0, 0)
|
||
heading_menu.expand
|
||
expect(heading_menu.option("[data-name='heading-2']")).to have_css(".d-icon-check")
|
||
heading_menu.collapse
|
||
|
||
composer.select_all
|
||
heading_menu.expand
|
||
expect(heading_menu.option("[data-name='heading-2']")).to have_no_css(".d-icon-check")
|
||
expect(heading_menu.option("[data-name='heading-4']")).to have_no_css(".d-icon-check")
|
||
end
|
||
|
||
it "can change heading level or reset to paragraph" do
|
||
open_composer
|
||
|
||
composer.type_content("This is a test")
|
||
heading_menu = composer.heading_menu
|
||
heading_menu.expand
|
||
heading_menu.option("[data-name='heading-2']").click
|
||
|
||
expect(rich).to have_css("h2", text: "This is a test")
|
||
|
||
heading_menu.expand
|
||
heading_menu.option("[data-name='heading-3']").click
|
||
expect(rich).to have_css("h3", text: "This is a test")
|
||
|
||
heading_menu.expand
|
||
heading_menu.option("[data-name='heading-paragraph']").click
|
||
expect(rich).to have_css("p", text: "This is a test")
|
||
end
|
||
|
||
it "can insert a heading on an empty line" do
|
||
open_composer
|
||
|
||
heading_menu = composer.heading_menu
|
||
heading_menu.expand
|
||
heading_menu.option("[data-name='heading-2']").click
|
||
|
||
composer.type_content("This is a test")
|
||
expect(rich).to have_css("h2", text: "This is a test")
|
||
end
|
||
end
|
||
|
||
describe "quote node" do
|
||
it "keeps the cursor outside quote when pasted" do
|
||
open_composer
|
||
|
||
markdown = "[quote]\nThis is a quote\n\n[/quote]"
|
||
cdp.copy_paste(markdown)
|
||
composer.type_content("This is a test")
|
||
|
||
composer.toggle_rich_editor
|
||
|
||
expect(composer).to have_value(markdown + "\n\nThis is a test")
|
||
end
|
||
|
||
# TODO: Failing often https://github.com/discourse/discourse/actions/runs/16891573420/job/47852388890
|
||
xit "lifts the first paragraph out of the quote with Backspace" do
|
||
open_composer
|
||
|
||
composer.type_content("[quote]Text")
|
||
expect(rich).to have_css("aside.quote blockquote p", text: "Text")
|
||
|
||
composer.send_keys(:home)
|
||
composer.send_keys(:backspace)
|
||
|
||
expect(rich).to have_no_css("aside.quote")
|
||
expect(rich).to have_css("p", text: "Text")
|
||
end
|
||
|
||
it "breaks out of the quote with a double Enter" do
|
||
open_composer
|
||
|
||
composer.type_content("[quote]Inside")
|
||
composer.send_keys(:enter)
|
||
composer.send_keys(:enter)
|
||
composer.type_content("Outside")
|
||
|
||
expect(rich).to have_css("aside.quote blockquote p", text: "Inside")
|
||
expect(rich).to have_css("aside.quote + p", text: "Outside")
|
||
end
|
||
end
|
||
end
|