From 8ca5d2fa1a77ccbb0773e15f56afb5003c34ad30 Mon Sep 17 00:00:00 2001 From: Go MAEDA Date: Wed, 9 Oct 2024 21:51:52 +0000 Subject: Partial quoting feature for Issues and Forums (#41294). Patch by Katsuya HIDAKA (user:hidakatsuya). git-svn-id: https://svn.redmine.org/redmine/trunk@23107 e93f8b46-1217-0410-a6f0-8f06a7374b81 --- app/assets/javascripts/application.js | 1 - app/assets/javascripts/quote_reply.js | 216 +++++++++++++++++++++++++++ app/assets/javascripts/turndown-7.2.0.min.js | 8 + app/controllers/journals_controller.rb | 18 +-- app/controllers/messages_controller.rb | 12 +- app/helpers/journals_helper.rb | 11 +- app/helpers/messages_helper.rb | 1 + app/views/issues/show.html.erb | 8 +- app/views/messages/show.html.erb | 27 ++-- 9 files changed, 260 insertions(+), 42 deletions(-) create mode 100644 app/assets/javascripts/quote_reply.js create mode 100644 app/assets/javascripts/turndown-7.2.0.min.js (limited to 'app') diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 82adf53a8..5a01f16c7 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -1261,7 +1261,6 @@ function inlineAutoComplete(element) { tribute.attach(element); } - $(document).ready(setupAjaxIndicator); $(document).ready(hideOnLoad); $(document).ready(addFormObserversForDoubleSubmit); diff --git a/app/assets/javascripts/quote_reply.js b/app/assets/javascripts/quote_reply.js new file mode 100644 index 000000000..7649f5125 --- /dev/null +++ b/app/assets/javascripts/quote_reply.js @@ -0,0 +1,216 @@ +function quoteReply(path, selectorForContentElement, textFormatting) { + const contentElement = $(selectorForContentElement).get(0); + const selectedRange = QuoteExtractor.extract(contentElement); + + 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 { + static extract(targetElement) { + return new QuoteExtractor(targetElement).extract(); + } + + constructor(targetElement) { + this.targetElement = targetElement; + this.selection = window.getSelection(); + } + + extract() { + const range = this.retriveSelectedRange(); + + if (!range) { + return null; + } + + if (!this.targetElement.contains(range.startContainer)) { + range.setStartBefore(this.targetElement); + } + if (!this.targetElement.contains(range.endContainer)) { + range.setEndAfter(this.targetElement); + } + + return range; + } + + retriveSelectedRange() { + if (!this.isSelected) { + return null; + } + + // Retrive the first range that intersects with the target element. + // NOTE: Firefox allows to select multiple ranges in the document. + for (let i = 0; i < this.selection.rangeCount; i++) { + let range = this.selection.getRangeAt(i); + if (range.intersectsNode(this.targetElement)) { + return range; + } + } + return null; + } + + get isSelected() { + return this.selection.containsNode(this.targetElement, true); + } +} + +class QuoteTextFormatter { + format(selectedRange) { + if (!selectedRange) { + return null; + } + + const fragment = document.createElement('div'); + fragment.appendChild(selectedRange.cloneContents()); + + // Remove all unnecessary anchor elements + fragment.querySelectorAll('a.wiki-anchor').forEach(e => e.remove()); + + const html = this.adjustLineBreaks(fragment.innerHTML); + + const result = document.createElement('div'); + result.innerHTML = html; + + // Replace continuous line breaks with a single line break and remove tab characters + return result.textContent + .trim() + .replace(/\t/g, '') + .replace(/\n+/g, "\n"); + } + + adjustLineBreaks(html) { + return html + .replace(/<\/(h1|h2|h3|h4|div|p|li|tr)>/g, "\n") + .replace(/
/g, "\n") + } +} + +class QuoteCommonMarkFormatter { + format(selectedRange) { + if (!selectedRange) { + return null; + } + + const htmlFragment = this.extractHtmlFragmentFrom(selectedRange); + const preparedHtml = this.prepareHtml(htmlFragment); + + return this.convertHtmlToCommonMark(preparedHtml); + } + + extractHtmlFragmentFrom(range) { + const fragment = document.createElement('div'); + const ancestorNodeName = range.commonAncestorContainer.nodeName; + + if (ancestorNodeName == 'CODE' || ancestorNodeName == '#text') { + fragment.appendChild(this.wrapPreCode(range)); + } else { + fragment.appendChild(range.cloneContents()); + } + + return fragment; + } + + // When only the content within the `` element is selected, + // the HTML within the selection range does not include the `
` element itself.
+  // To create a complete code block, wrap the selected content with the `
` tags.
+  //
+  // selected contentes => 
selected contents
+ wrapPreCode(range) { + const rangeAncestor = range.commonAncestorContainer; + + let codeElement = null; + + if (rangeAncestor.nodeName == 'CODE') { + codeElement = rangeAncestor; + } else { + codeElement = rangeAncestor.parentElement.closest('code'); + } + + if (!codeElement) { + return range.cloneContents(); + } + + const pre = document.createElement('pre'); + const code = codeElement.cloneNode(false); + + code.appendChild(range.cloneContents()); + pre.appendChild(code); + + return pre; + } + + convertHtmlToCommonMark(html) { + const turndownService = new TurndownService({ + codeBlockStyle: 'fenced', + headingStyle: 'atx' + }); + + turndownService.addRule('del', { + filter: ['del'], + replacement: content => `~~${content}~~` + }); + + turndownService.addRule('checkList', { + filter: node => { + return node.type === 'checkbox' && node.parentNode.nodeName === 'LI'; + }, + replacement: (content, node) => { + return node.checked ? '[x]' : '[ ]'; + } + }); + + // Table does not maintain its original format, + // and the text within the table is displayed as it is + // + // | A | B | C | + // |---|---|---| + // | 1 | 2 | 3 | + // => + // A B C + // 1 2 3 + turndownService.addRule('table', { + filter: ['td', 'th'], + replacement: (content, node) => { + const separator = node.parentElement.lastElementChild === node ? '' : ' '; + return content + separator; + } + }); + turndownService.addRule('tableHeading', { + filter: ['thead', 'tbody', 'tfoot', 'tr'], + replacement: (content, _node) => content + }); + turndownService.addRule('tableRow', { + filter: ['tr'], + replacement: (content, _node) => { + return content + '\n' + } + }); + + return turndownService.turndown(html); + } + + prepareHtml(htmlFragment) { + // Remove all anchor elements. + //

Title1ΒΆ

=>

Title1

+ htmlFragment.querySelectorAll('a.wiki-anchor').forEach(e => e.remove()); + + // Convert code highlight blocks to CommonMark format code blocks. + // => + htmlFragment.querySelectorAll('code[data-language]').forEach(e => { + e.classList.replace(e.dataset['language'], 'language-' + e.dataset['language']) + }); + + return htmlFragment.innerHTML; + } +} diff --git a/app/assets/javascripts/turndown-7.2.0.min.js b/app/assets/javascripts/turndown-7.2.0.min.js new file mode 100644 index 000000000..f3fb4b1e6 --- /dev/null +++ b/app/assets/javascripts/turndown-7.2.0.min.js @@ -0,0 +1,8 @@ +/* + * Turndown v7.2.0 + * https://github.com/mixmark-io/turndown + * Copyright (c) 2017 Dom Christie + * Released under the MIT license + * https://github.com/mixmark-io/turndown/blob/master/LICENSE + */ +var TurndownService=(()=>{function u(e,n){return Array(n+1).join(e)}var n=["ADDRESS","ARTICLE","ASIDE","AUDIO","BLOCKQUOTE","BODY","CANVAS","CENTER","DD","DIR","DIV","DL","DT","FIELDSET","FIGCAPTION","FIGURE","FOOTER","FORM","FRAMESET","H1","H2","H3","H4","H5","H6","HEADER","HGROUP","HR","HTML","ISINDEX","LI","MAIN","MENU","NAV","NOFRAMES","NOSCRIPT","OL","OUTPUT","P","PRE","SECTION","TABLE","TBODY","TD","TFOOT","TH","THEAD","TR","UL"];function f(e){return o(e,n)}var r=["AREA","BASE","BR","COL","COMMAND","EMBED","HR","IMG","INPUT","KEYGEN","LINK","META","PARAM","SOURCE","TRACK","WBR"];function d(e){return o(e,r)}var i=["A","TABLE","THEAD","TBODY","TFOOT","TH","TD","IFRAME","SCRIPT","AUDIO","VIDEO"];function o(e,n){return 0<=n.indexOf(e.nodeName)}function a(n,e){return n.getElementsByTagName&&e.some(function(e){return n.getElementsByTagName(e).length})}var t={};function c(e){return e?e.replace(/(\n+\s*)+/g,"\n"):""}function l(e){for(var n in this.options=e,this._keep=[],this._remove=[],this.blankRule={replacement:e.blankReplacement},this.keepReplacement=e.keepReplacement,this.defaultRule={replacement:e.defaultReplacement},this.array=[],e.rules)this.array.push(e.rules[n])}function s(e,n,t){for(var r=0;r{var r=e.filter;if("string"==typeof r)return r===n.nodeName.toLowerCase();if(Array.isArray(r))return-1 "))+"\n\n"}},t.list={filter:["ul","ol"],replacement:function(e,n){var t=n.parentNode;return"LI"===t.nodeName&&t.lastElementChild===n?"\n"+e:"\n\n"+e+"\n\n"}},t.listItem={filter:"li",replacement:function(e,n,t){e=e.replace(/^\n+/,"").replace(/\n+$/,"\n").replace(/\n/gm,"\n ");var r,t=t.bulletListMarker+" ",i=n.parentNode;return"OL"===i.nodeName&&(r=i.getAttribute("start"),i=Array.prototype.indexOf.call(i.children,n),t=(r?Number(r)+i:i+1)+". "),t+e+(n.nextSibling&&!/\n$/.test(e)?"\n":"")}},t.indentedCodeBlock={filter:function(e,n){return"indented"===n.codeBlockStyle&&"PRE"===e.nodeName&&e.firstChild&&"CODE"===e.firstChild.nodeName},replacement:function(e,n,t){return"\n\n "+n.firstChild.textContent.replace(/\n/g,"\n ")+"\n\n"}},t.fencedCodeBlock={filter:function(e,n){return"fenced"===n.codeBlockStyle&&"PRE"===e.nodeName&&e.firstChild&&"CODE"===e.firstChild.nodeName},replacement:function(e,n,t){for(var r,i=((n.firstChild.getAttribute("class")||"").match(/language-(\S+)/)||[null,""])[1],o=n.firstChild.textContent,n=t.fence.charAt(0),a=3,l=new RegExp("^"+n+"{3,}","gm");r=l.exec(o);)r[0].length>=a&&(a=r[0].length+1);t=u(n,a);return"\n\n"+t+i+"\n"+o.replace(/\n$/,"")+"\n"+t+"\n\n"}},t.horizontalRule={filter:"hr",replacement:function(e,n,t){return"\n\n"+t.hr+"\n\n"}},t.inlineLink={filter:function(e,n){return"inlined"===n.linkStyle&&"A"===e.nodeName&&e.getAttribute("href")},replacement:function(e,n){var t=(t=n.getAttribute("href"))&&t.replace(/([()])/g,"\\$1"),n=c(n.getAttribute("title"));return"["+e+"]("+t+(n=n&&' "'+n.replace(/"/g,'\\"')+'"')+")"}},t.referenceLink={filter:function(e,n){return"referenced"===n.linkStyle&&"A"===e.nodeName&&e.getAttribute("href")},replacement:function(e,n,t){var r=n.getAttribute("href"),i=(i=c(n.getAttribute("title")))&&' "'+i+'"';switch(t.linkReferenceStyle){case"collapsed":a="["+e+"][]",l="["+e+"]: "+r+i;break;case"shortcut":a="["+e+"]",l="["+e+"]: "+r+i;break;default:var o=this.references.length+1,a="["+e+"]["+o+"]",l="["+o+"]: "+r+i}return this.references.push(l),a},references:[],append:function(e){var n="";return this.references.length&&(n="\n\n"+this.references.join("\n")+"\n\n",this.references=[]),n}},t.emphasis={filter:["em","i"],replacement:function(e,n,t){return e.trim()?t.emDelimiter+e+t.emDelimiter:""}},t.strong={filter:["strong","b"],replacement:function(e,n,t){return e.trim()?t.strongDelimiter+e+t.strongDelimiter:""}},t.code={filter:function(e){var n=e.previousSibling||e.nextSibling,n="PRE"===e.parentNode.nodeName&&!n;return"CODE"===e.nodeName&&!n},replacement:function(e){if(!e)return"";e=e.replace(/\r?\n|\r/g," ");for(var n=/^`|^ .*?[^ ].* $|`$/.test(e)?" ":"",t="`",r=e.match(/`+/gm)||[];-1!==r.indexOf(t);)t+="`";return t+n+e+n+t}},t.image={filter:"img",replacement:function(e,n){var t=c(n.getAttribute("alt")),r=n.getAttribute("src")||"",n=c(n.getAttribute("title"));return r?"!["+t+"]("+r+(n?' "'+n+'"':"")+")":""}},l.prototype={add:function(e,n){this.array.unshift(n)},keep:function(e){this._keep.unshift({filter:e,replacement:this.keepReplacement})},remove:function(e){this._remove.unshift({filter:e,replacement:function(){return""}})},forNode:function(e){return e.isBlank?this.blankRule:s(this.array,e,this.options)||s(this._keep,e,this.options)||s(this._remove,e,this.options)||this.defaultRule},forEach:function(e){for(var n=0;n{var e=m.DOMParser,n=!1;try{(new e).parseFromString("","text/html")&&(n=!0)}catch(e){}return n})()?m.DOMParser:((()=>{var n=!1;try{document.implementation.createHTMLDocument("").open()}catch(e){m.ActiveXObject&&(n=!0)}return n})()?e.prototype.parseFromString=function(e){var n=new window.ActiveXObject("htmlfile");return n.designMode="on",n.open(),n.write(e),n.close(),n}:e.prototype.parseFromString=function(e){var n=document.implementation.createHTMLDocument("");return n.open(),n.write(e),n.close(),n},e);function e(){}function y(e,n){var n={element:e="string"==typeof e?(g=g||new A).parseFromString(''+e+"","text/html").getElementById("turndown-root"):e.cloneNode(!0),isBlock:f,isVoid:d,isPre:n.preformattedCode?v:null},t=n.element,r=n.isBlock,i=n.isVoid,o=n.isPre||function(e){return"PRE"===e.nodeName};if(t.firstChild&&!o(t)){for(var a=null,l=!1,u=h(s=null,t,o);u!==t;){if(3===u.nodeType||4===u.nodeType){var c=u.data.replace(/[ \r\n\t]+/g," ");if(!(c=a&&!/ $/.test(a.data)||l||" "!==c[0]?c:c.substr(1))){u=p(u);continue}u.data=c,a=u}else{if(1!==u.nodeType){u=p(u);continue}r(u)||"BR"===u.nodeName?(a&&(a.data=a.data.replace(/ $/,"")),a=null,l=!1):i(u)||o(u)?l=!(a=null):a&&(l=!1)}var c=h(s,u,o),s=u,u=c}a&&(a.data=a.data.replace(/ $/,""),a.data||p(a))}return e}function v(e){return"PRE"===e.nodeName||"CODE"===e.nodeName}function N(e,n){var t;return e.isBlock=f(e),e.isCode="CODE"===e.nodeName||e.parentNode.isCode,e.isBlank=!d(t=e)&&!(e=>o(e,i))(t)&&/^\s*$/i.test(t.textContent)&&!(e=>a(e,r))(t)&&!(e=>a(e,i))(t),e.flankingWhitespace=((e,n)=>{var t;return e.isBlock||n.preformattedCode&&e.isCode?{leading:"",trailing:""}:((t=(e=>({leading:(e=e.match(/^(([ \t\r\n]*)(\s*))(?:(?=\S)[\s\S]*\S)?((\s*?)([ \t\r\n]*))$/))[1],leadingAscii:e[2],leadingNonAscii:e[3],trailing:e[4],trailingNonAscii:e[5],trailingAscii:e[6]}))(e.textContent)).leadingAscii&&E("left",e,n)&&(t.leading=t.leadingNonAscii),t.trailingAscii&&E("right",e,n)&&(t.trailing=t.trailingNonAscii),{leading:t.leading,trailing:t.trailing})})(e,n),e}function E(e,n,t){var r,i,e="left"===e?(r=n.previousSibling,/ $/):(r=n.nextSibling,/^ /);return r&&(3===r.nodeType?i=e.test(r.nodeValue):t.preformattedCode&&"CODE"===r.nodeName?i=!1:1!==r.nodeType||f(r)||(i=e.test(r.textContent))),i}var T=Array.prototype.reduce,R=[[/\\/g,"\\\\"],[/\*/g,"\\*"],[/^-/g,"\\-"],[/^\+ /g,"\\+ "],[/^(=+)/g,"\\$1"],[/^(#{1,6}) /g,"\\$1 "],[/`/g,"\\`"],[/^~~~/g,"\\~~~"],[/\[/g,"\\["],[/\]/g,"\\]"],[/^>/g,"\\>"],[/_/g,"\\_"],[/^(\d+)\. /g,"$1\\. "]];function C(e){if(!(this instanceof C))return new C(e);this.options=function(e){for(var n=1;n{for(var n=e.length;0 " - else - user = @issue.author - text = @issue.description - @content = "#{ll(Setting.default_language, :text_user_wrote, user)}\n> " - end - # Replaces pre blocks with [...] - text = text.to_s.strip.gsub(%r{
(.*?)
}m, '[...]') - @content << text.gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n" + @content = if @journal + quote_issue_journal(@journal, indice: params[:journal_indice], partial_quote: params[:quote]) + else + quote_issue(@issue, partial_quote: params[:quote]) + end rescue ActiveRecord::RecordNotFound render_404 end diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb index 5050d94a7..5159bf540 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -29,6 +29,7 @@ class MessagesController < ApplicationController helper :watchers helper :attachments include AttachmentsHelper + include Redmine::QuoteReply::Builder REPLIES_PER_PAGE = 25 unless const_defined?(:REPLIES_PER_PAGE) @@ -119,12 +120,11 @@ class MessagesController < ApplicationController @subject = @message.subject @subject = "RE: #{@subject}" unless @subject.starts_with?('RE:') - if @message.root == @message - @content = "#{ll(Setting.default_language, :text_user_wrote, @message.author)}\n> " - else - @content = "#{ll(Setting.default_language, :text_user_wrote_in, {:value => @message.author, :link => "message##{@message.id}"})}\n> " - end - @content << @message.content.to_s.strip.gsub(%r{
(.*?)
}m, '[...]').gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n" + @content = if @message.root == @message + quote_root_message(@message, partial_quote: params[:quote]) + else + quote_message(@message, partial_quote: params[:quote]) + end respond_to do |format| format.html { render_404 } diff --git a/app/helpers/journals_helper.rb b/app/helpers/journals_helper.rb index e51207e0e..e4c23995e 100644 --- a/app/helpers/journals_helper.rb +++ b/app/helpers/journals_helper.rb @@ -18,6 +18,8 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. module JournalsHelper + include Redmine::QuoteReply::Helper + # Returns the attachments of a journal that are displayed as thumbnails def journal_thumbnail_attachments(journal) journal.attachments.select(&:thumbnailable?) @@ -40,13 +42,8 @@ module JournalsHelper if journal.notes.present? if options[:reply_links] - links << link_to(icon_with_label('comment', l(:button_quote)), - quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice), - :remote => true, - :method => 'post', - :title => l(:button_quote), - :class => 'icon-only icon-comment' - ) + url = quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice) + links << quote_reply(url, "#journal-#{journal.id}-notes", icon_only: true) end if journal.editable_by?(User.current) links << link_to(icon_with_label('edit', l(:button_edit)), diff --git a/app/helpers/messages_helper.rb b/app/helpers/messages_helper.rb index 24218e46e..fd9ba3bcb 100644 --- a/app/helpers/messages_helper.rb +++ b/app/helpers/messages_helper.rb @@ -18,4 +18,5 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. module MessagesHelper + include Redmine::QuoteReply::Helper end diff --git a/app/views/issues/show.html.erb b/app/views/issues/show.html.erb index 13c1bc632..8f732032a 100644 --- a/app/views/issues/show.html.erb +++ b/app/views/issues/show.html.erb @@ -1,3 +1,7 @@ +<% content_for :header_tags do %> + <%= javascripts_for_quote_reply_include_tag %> +<% end %> + <%= render :partial => 'action_menu' %>

<%= issue_heading(@issue) %>

<%= issue_status_type_badge(@issue.status) %> @@ -84,11 +88,11 @@ end %>
- <%= link_to icon_with_label('comment', l(:button_quote)), quoted_issue_path(@issue), :remote => true, :method => 'post', :class => 'icon icon-comment ' if @issue.notes_addable? %> + <%= quote_reply(quoted_issue_path(@issue), '#issue_description_wiki') if @issue.notes_addable? %>

<%=l(:field_description)%>

-
+
<%= textilizable @issue, :description, :attachments => @issue.attachments %>
diff --git a/app/views/messages/show.html.erb b/app/views/messages/show.html.erb index 64c69a0fe..87355e65d 100644 --- a/app/views/messages/show.html.erb +++ b/app/views/messages/show.html.erb @@ -1,13 +1,15 @@ +<% content_for :header_tags do %> + <%= javascripts_for_quote_reply_include_tag %> +<% end %> + <%= board_breadcrumb(@message) %>
<%= watcher_link(@topic, User.current) %> - <%= link_to( - icon_with_label('comment', l(:button_quote)), - {:action => 'quote', :id => @topic}, - :method => 'get', - :class => 'icon icon-comment', - :remote => true) if !@topic.locked? && authorize_for('messages', 'reply') %> + <%= quote_reply( + url_for(:action => 'quote', :id => @topic, :format => 'js'), + '#message_topic_wiki' + ) if !@topic.locked? && authorize_for('messages', 'reply') %> <%= link_to( icon_with_label('edit', l(:button_edit)), {:action => 'edit', :id => @topic}, @@ -26,7 +28,7 @@

<%= authoring @topic.created_on, @topic.author %>

-
+
<%= textilizable(@topic, :content) %>
<%= link_to_attachments @topic, :author => false, :thumbnails => true %> @@ -42,13 +44,10 @@ <% @replies.each do |message| %>
">
- <%= link_to( - icon_with_label('comment', l(:button_quote), icon_only: true), - {:action => 'quote', :id => message}, - :remote => true, - :method => 'get', - :title => l(:button_quote), - :class => 'icon icon-comment' + <%= quote_reply( + url_for(:action => 'quote', :id => message, :format => 'js'), + "#message-#{message.id} .wiki", + icon_only: true ) if !@topic.locked? && authorize_for('messages', 'reply') %> <%= link_to( icon_with_label('edit', l(:button_edit), icon_only: true), -- cgit v1.2.3