mirror of
https://github.com/redmine/redmine.git
synced 2025-10-29 09:16:23 +01:00
Reimplement partial quote feature using Stimulus JS (#42515).
Patch by Katsuya HIDAKA (user:hidakatsuya). git-svn-id: https://svn.redmine.org/redmine/trunk@23854 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
1
Gemfile
1
Gemfile
@@ -22,6 +22,7 @@ gem 'commonmarker', '~> 2.3.0'
|
|||||||
gem "doorkeeper", "~> 5.8.2"
|
gem "doorkeeper", "~> 5.8.2"
|
||||||
gem "bcrypt", require: false
|
gem "bcrypt", require: false
|
||||||
gem "doorkeeper-i18n", "~> 5.2"
|
gem "doorkeeper-i18n", "~> 5.2"
|
||||||
|
gem "requestjs-rails", "~> 0.0.13"
|
||||||
|
|
||||||
# Ruby Standard Gems
|
# Ruby Standard Gems
|
||||||
gem 'csv', '~> 3.3.2'
|
gem 'csv', '~> 3.3.2'
|
||||||
|
|||||||
@@ -1,21 +1,6 @@
|
|||||||
function quoteReply(path, selectorForContentElement, textFormatting) {
|
import { Controller } from '@hotwired/stimulus'
|
||||||
const contentElement = $(selectorForContentElement).get(0);
|
import TurndownService from 'turndown'
|
||||||
const selectedRange = QuoteExtractor.extract(contentElement);
|
import { post } from '@rails/request.js'
|
||||||
|
|
||||||
let formatter;
|
|
||||||
|
|
||||||
if (textFormatting === 'common_mark') {
|
|
||||||
formatter = new QuoteCommonMarkFormatter();
|
|
||||||
} else {
|
|
||||||
formatter = new QuoteTextFormatter();
|
|
||||||
}
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: path,
|
|
||||||
type: 'post',
|
|
||||||
data: { quote: formatter.format(selectedRange) }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class QuoteExtractor {
|
class QuoteExtractor {
|
||||||
static extract(targetElement) {
|
static extract(targetElement) {
|
||||||
@@ -214,3 +199,26 @@ class QuoteCommonMarkFormatter {
|
|||||||
return htmlFragment.innerHTML;
|
return htmlFragment.innerHTML;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = [ 'content' ];
|
||||||
|
|
||||||
|
quote(event) {
|
||||||
|
const { url, textFormatting } = event.params;
|
||||||
|
const selectedRange = QuoteExtractor.extract(this.contentTarget);
|
||||||
|
|
||||||
|
let formatter;
|
||||||
|
|
||||||
|
if (textFormatting === 'common_mark') {
|
||||||
|
formatter = new QuoteCommonMarkFormatter();
|
||||||
|
} else {
|
||||||
|
formatter = new QuoteTextFormatter();
|
||||||
|
}
|
||||||
|
|
||||||
|
post(url, {
|
||||||
|
body: JSON.stringify({ quote: formatter.format(selectedRange) }),
|
||||||
|
contentType: 'application/json',
|
||||||
|
responseKind: 'script'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
8
app/assets/javascripts/turndown-7.2.0.min.js
vendored
8
app/assets/javascripts/turndown-7.2.0.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -46,7 +46,7 @@ module JournalsHelper
|
|||||||
if journal.notes.present?
|
if journal.notes.present?
|
||||||
if options[:reply_links]
|
if options[:reply_links]
|
||||||
url = quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice)
|
url = quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice)
|
||||||
links << quote_reply(url, "#journal-#{journal.id}-notes", icon_only: true)
|
links << quote_reply_button(url: url, icon_only: true)
|
||||||
end
|
end
|
||||||
if journal.editable_by?(User.current)
|
if journal.editable_by?(User.current)
|
||||||
links << link_to(sprite_icon('edit', l(:button_edit)),
|
links << link_to(sprite_icon('edit', l(:button_edit)),
|
||||||
@@ -69,7 +69,8 @@ module JournalsHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def render_notes(issue, journal, options={})
|
def render_notes(issue, journal, options={})
|
||||||
content_tag('div', textilizable(journal, :notes), :id => "journal-#{journal.id}-notes", :class => "wiki")
|
content_tag('div', textilizable(journal, :notes),
|
||||||
|
id: "journal-#{journal.id}-notes", class: "wiki", data: { quote_reply_target: 'content' })
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_private_notes_indicator(journal)
|
def render_private_notes_indicator(journal)
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
<% content_for :header_tags do %>
|
|
||||||
<%= javascripts_for_quote_reply_include_tag %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= render :partial => 'action_menu' %>
|
<%= render :partial => 'action_menu' %>
|
||||||
|
|
||||||
<h2 class="inline-block"><%= issue_heading(@issue) %></h2><%= issue_status_type_badge(@issue.status) %>
|
<h2 class="inline-block"><%= issue_heading(@issue) %></h2><%= issue_status_type_badge(@issue.status) %>
|
||||||
@@ -96,13 +92,13 @@ end %>
|
|||||||
|
|
||||||
<% if @issue.description? %>
|
<% if @issue.description? %>
|
||||||
<hr />
|
<hr />
|
||||||
<div class="description">
|
<div class="description" data-controller="quote-reply">
|
||||||
<div class="contextual">
|
<div class="contextual">
|
||||||
<%= quote_reply(quoted_issue_path(@issue), '#issue_description_wiki') if @issue.notes_addable? %>
|
<%= quote_reply_button(url: quoted_issue_path(@issue)) if @issue.notes_addable? %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p><strong><%=l(:field_description)%></strong></p>
|
<p><strong><%=l(:field_description)%></strong></p>
|
||||||
<div id="issue_description_wiki" class="wiki">
|
<div id="issue_description_wiki" class="wiki" data-quote-reply-target="content">
|
||||||
<%= textilizable @issue, :description, :attachments => @issue.attachments %>
|
<%= textilizable @issue, :description, :attachments => @issue.attachments %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<% reply_links = issue.notes_addable? -%>
|
<% reply_links = issue.notes_addable? -%>
|
||||||
<% for journal in journals %>
|
<% for journal in journals %>
|
||||||
<div id="change-<%= journal.id %>" class="<%= journal.css_classes %>">
|
<div id="change-<%= journal.id %>" class="<%= journal.css_classes %>" data-controller="quote-reply">
|
||||||
<div id="note-<%= journal.indice %>" class="note">
|
<div id="note-<%= journal.indice %>" class="note">
|
||||||
<div class="contextual">
|
<div class="contextual">
|
||||||
<span class="journal-actions"><%= render_journal_actions(issue, journal, :reply_links => reply_links) %></span>
|
<span class="journal-actions"><%= render_journal_actions(issue, journal, :reply_links => reply_links) %></span>
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
<% content_for :header_tags do %>
|
|
||||||
<%= javascripts_for_quote_reply_include_tag %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= board_breadcrumb(@message) %>
|
<%= board_breadcrumb(@message) %>
|
||||||
|
|
||||||
|
<div data-controller="quote-reply">
|
||||||
<div class="contextual">
|
<div class="contextual">
|
||||||
<%= watcher_link(@topic, User.current) %>
|
<%= watcher_link(@topic, User.current) %>
|
||||||
<%= quote_reply(
|
<%= quote_reply_button(
|
||||||
url_for(:action => 'quote', :id => @topic, :format => 'js'),
|
url: url_for(action: 'quote', id: @topic, format: 'js')
|
||||||
'#message_topic_wiki'
|
|
||||||
) if !@topic.locked? && authorize_for('messages', 'reply') %>
|
) if !@topic.locked? && authorize_for('messages', 'reply') %>
|
||||||
<%= link_to(
|
<%= link_to(
|
||||||
sprite_icon('edit', l(:button_edit)),
|
sprite_icon('edit', l(:button_edit)),
|
||||||
@@ -31,11 +27,12 @@
|
|||||||
<%= reaction_button @topic %>
|
<%= reaction_button @topic %>
|
||||||
</div>
|
</div>
|
||||||
<p><span class="author"><%= authoring @topic.created_on, @topic.author %></span></p>
|
<p><span class="author"><%= authoring @topic.created_on, @topic.author %></span></p>
|
||||||
<div id="message_topic_wiki" class="wiki">
|
<div id="message_topic_wiki" class="wiki" data-quote-reply-target="content">
|
||||||
<%= textilizable(@topic, :content) %>
|
<%= textilizable(@topic, :content) %>
|
||||||
</div>
|
</div>
|
||||||
<%= link_to_attachments @topic, :author => false, :thumbnails => true %>
|
<%= link_to_attachments @topic, :author => false, :thumbnails => true %>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<% unless @replies.empty? %>
|
<% unless @replies.empty? %>
|
||||||
@@ -45,12 +42,11 @@
|
|||||||
<p><%= toggle_link l(:button_reply), "reply", :focus => 'message_content', :scroll => "message_content" %></p>
|
<p><%= toggle_link l(:button_reply), "reply", :focus => 'message_content', :scroll => "message_content" %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% @replies.each do |message| %>
|
<% @replies.each do |message| %>
|
||||||
<div class="message reply" id="<%= "message-#{message.id}" %>">
|
<div class="message reply" id="<%= "message-#{message.id}" %>" data-controller="quote-reply">
|
||||||
<div class="contextual">
|
<div class="contextual">
|
||||||
<%= reaction_button message %>
|
<%= reaction_button message %>
|
||||||
<%= quote_reply(
|
<%= quote_reply_button(
|
||||||
url_for(:action => 'quote', :id => message, :format => 'js'),
|
url: url_for(action: 'quote', id: message, format: 'js'),
|
||||||
"#message-#{message.id} .wiki",
|
|
||||||
icon_only: true
|
icon_only: true
|
||||||
) if !@topic.locked? && authorize_for('messages', 'reply') %>
|
) if !@topic.locked? && authorize_for('messages', 'reply') %>
|
||||||
<%= link_to(
|
<%= link_to(
|
||||||
@@ -74,7 +70,9 @@
|
|||||||
-
|
-
|
||||||
<%= authoring message.created_on, message.author %>
|
<%= authoring message.created_on, message.author %>
|
||||||
</h4>
|
</h4>
|
||||||
<div class="wiki"><%= textilizable message, :content, :attachments => message.attachments %></div>
|
<div class="wiki" data-quote-reply-target="content">
|
||||||
|
<%= textilizable message, :content, :attachments => message.attachments %>
|
||||||
|
</div>
|
||||||
<%= link_to_attachments message, :author => false, :thumbnails => true %>
|
<%= link_to_attachments message, :author => false, :thumbnails => true %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -5,4 +5,5 @@
|
|||||||
pin "application"
|
pin "application"
|
||||||
pin "@hotwired/stimulus", to: "stimulus.min.js"
|
pin "@hotwired/stimulus", to: "stimulus.min.js"
|
||||||
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
|
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
|
||||||
|
pin "turndown" # @7.2.0
|
||||||
pin_all_from "app/javascript/controllers", under: "controllers"
|
pin_all_from "app/javascript/controllers", under: "controllers"
|
||||||
|
|||||||
@@ -20,21 +20,18 @@
|
|||||||
module Redmine
|
module Redmine
|
||||||
module QuoteReply
|
module QuoteReply
|
||||||
module Helper
|
module Helper
|
||||||
def javascripts_for_quote_reply_include_tag
|
def quote_reply_button(url:, icon_only: false)
|
||||||
javascript_include_tag 'turndown-7.2.0.min', 'quote_reply'
|
button_params = {
|
||||||
end
|
data: {
|
||||||
|
action: 'quote-reply#quote',
|
||||||
|
quote_reply_url_param: url,
|
||||||
|
quote_reply_text_formatting_param: Setting.text_formatting
|
||||||
|
},
|
||||||
|
class: "#{icon_only ? "icon-only" : "icon"} icon-quote"
|
||||||
|
}
|
||||||
|
button_params[:title] = l(:button_quote) if icon_only
|
||||||
|
|
||||||
def quote_reply(url, selector_for_content, icon_only: false)
|
link_to sprite_icon('quote-filled', l(:button_quote), icon_only: icon_only, style: :filled), '#', button_params
|
||||||
quote_reply_function = "quoteReply('#{j url}', '#{j selector_for_content}', '#{j Setting.text_formatting}')"
|
|
||||||
|
|
||||||
html_options = { class: "#{icon_only ? "icon-only" : "icon"} icon-quote" }
|
|
||||||
html_options[:title] = l(:button_quote) if icon_only
|
|
||||||
|
|
||||||
link_to_function(
|
|
||||||
sprite_icon('quote-filled', l(:button_quote), icon_only: icon_only, style: :filled),
|
|
||||||
quote_reply_function,
|
|
||||||
html_options
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ require_relative '../application_system_test_case'
|
|||||||
class MessagesTest < ApplicationSystemTestCase
|
class MessagesTest < ApplicationSystemTestCase
|
||||||
def test_reply_to_topic_message
|
def test_reply_to_topic_message
|
||||||
with_text_formatting 'common_mark' do
|
with_text_formatting 'common_mark' do
|
||||||
within '#content > .contextual' do
|
within '#content > [data-controller="quote-reply"]' do
|
||||||
click_link 'Quote'
|
click_link 'Quote'
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ class MessagesTest < ApplicationSystemTestCase
|
|||||||
window.getSelection().addRange(range);
|
window.getSelection().addRange(range);
|
||||||
JS
|
JS
|
||||||
|
|
||||||
within '#content > .contextual' do
|
within '#content > [data-controller="quote-reply"]' do
|
||||||
click_link 'Quote'
|
click_link 'Quote'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -23,18 +23,18 @@ class QuoteReplyHelperTest < ActionView::TestCase
|
|||||||
include ERB::Util
|
include ERB::Util
|
||||||
include Redmine::QuoteReply::Helper
|
include Redmine::QuoteReply::Helper
|
||||||
|
|
||||||
def test_quote_reply
|
def test_quote_reply_button
|
||||||
with_locale 'en' do
|
with_locale 'en' do
|
||||||
url = quoted_issue_path(issues(:issues_001))
|
url = quoted_issue_path(issues(:issues_001))
|
||||||
|
|
||||||
a_tag = quote_reply(url, '#issue_description_wiki')
|
html = quote_reply_button(url: url)
|
||||||
assert_includes a_tag, %|onclick="#{h "quoteReply('/issues/1/quoted', '#issue_description_wiki', 'common_mark'); return false;"}"|
|
assert_select_in html,
|
||||||
assert_includes a_tag, %|class="icon icon-quote"|
|
'a[data-quote-reply-url-param=?][data-quote-reply-text-formatting-param=?]:not([title])',
|
||||||
assert_not_includes a_tag, 'title='
|
url, Setting.text_formatting
|
||||||
|
|
||||||
# When icon_only is true
|
# When icon_only is true
|
||||||
a_tag = quote_reply(url, '#issue_description_wiki', icon_only: true)
|
html = quote_reply_button(url: url, icon_only: true)
|
||||||
assert_includes a_tag, %|title="Quote"|
|
assert_select_in html, 'a.icon-only.icon-quote[title=?]', 'Quote'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
110
vendor/javascript/turndown.js
vendored
Normal file
110
vendor/javascript/turndown.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user