Project

General

Profile

Feature #41294 » 0007-Allow-replying-qith-quotes-in-CommonMark-format.patch

Katsuya HIDAKA, 2024-09-22 13:01

View differences:

app/assets/javascripts/quote_reply.js
1
function quoteReply(path, selectorForContentElement) {
1
function quoteReply(path, selectorForContentElement, textFormatting) {
2 2
  const contentElement = $(selectorForContentElement).get(0);
3
  const quote = QuoteExtractor.extract(contentElement);
3
  const selectedRange = QuoteExtractor.extract(contentElement);
4

  
5
  let formatter;
6

  
7
  if (textFormatting === 'common_mark') {
8
    formatter = new QuoteCommonMarkFormatter();
9
  } else {
10
    formatter = new QuoteTextFormatter();
11
  }
4 12

  
5 13
  $.ajax({
6 14
    url: path,
7 15
    type: 'post',
8
    data: { quote: quote }
16
    data: { quote: formatter.format(selectedRange) }
9 17
  });
10 18
}
11 19

  
......
20 28
  }
21 29

  
22 30
  extract() {
23
    const range = this.selectedRange;
31
    const range = this.retriveSelectedRange();
24 32

  
25 33
    if (!range) {
26 34
      return null;
......
33 41
      range.setEndAfter(this.targetElement);
34 42
    }
35 43

  
36
    return this.formatRange(range);
44
    return range;
37 45
  }
38 46

  
39
  formatRange(range) {
40
    return range.toString().trim();
41
  }
42

  
43
  get selectedRange() {
47
  retriveSelectedRange() {
44 48
    if (!this.isSelected) {
45 49
      return null;
46 50
    }
......
60 64
    return this.selection.containsNode(this.targetElement, true);
61 65
  }
62 66
}
67

  
68
class QuoteTextFormatter {
69
  format(selectedRange) {
70
    if (!selectedRange) {
71
      return null;
72
    }
73

  
74
    const fragment = document.createElement('div');
75
    fragment.appendChild(selectedRange.cloneContents());
76

  
77
    // Remove all unnecessary anchor elements
78
    fragment.querySelectorAll('a.wiki-anchor').forEach(e => e.remove());
79

  
80
    // Adjust line breaks when output as text
81
    const html = this.adjustLineBreaks(fragment.innerHTML);
82

  
83
    const result = document.createElement('div');
84
    result.innerHTML = html;
85

  
86
    // Replace continuous line breaks with a single line break and remove tab characters
87
    return result.textContent
88
      .trim()
89
      .replace(/\t/g, '')
90
      .replace(/\n+/g, "\n");
91
  }
92

  
93
  adjustLineBreaks(html) {
94
    return html
95
      .replace(/<\/(h1|h2|h3|h4|div|p|li|tr)>/g, "\n</$1>")
96
      .replace(/<br>/g, "\n")
97
  }
98
}
99

  
100
class QuoteCommonMarkFormatter {
101
  format(selectedRange) {
102
    if (!selectedRange) {
103
      return null;
104
    }
105

  
106
    const htmlFragment = this.extractHtmlFragmentFrom(selectedRange);
107
    const preparedHtml = this.prepareHtml(htmlFragment);
108

  
109
    return this.convertHtmlToCommonMark(preparedHtml);
110
  }
111

  
112
  extractHtmlFragmentFrom(range) {
113
    const fragment = document.createElement('div');
114
    const ancestorNodeName = range.commonAncestorContainer.nodeName;
115

  
116
    if (ancestorNodeName == 'CODE' || ancestorNodeName == '#text') {
117
      fragment.appendChild(this.wrapPreCode(range));
118
    } else {
119
      fragment.appendChild(range.cloneContents());
120
    }
121

  
122
    return fragment;
123
  }
124

  
125
  // When only the content within the `<code>` element is selected,
126
  // the HTML within the selection range does not include the `<pre><code>` element itself.
127
  // To create a complete code block, wrap the selected content with the `<pre><code>` tags.
128
  //
129
  // selected contentes => <pre><code class="ruby">selected contents</code></pre>
130
  wrapPreCode(range) {
131
    const rangeAncestor = range.commonAncestorContainer;
132

  
133
    let codeElement = null;
134

  
135
    if (rangeAncestor.nodeName == 'CODE') {
136
      codeElement = rangeAncestor;
137
    } else {
138
      codeElement = rangeAncestor.parentElement.closest('code');
139
    }
140

  
141
    if (!codeElement) {
142
      return range.cloneContents();
143
    }
144

  
145
    const pre = document.createElement('pre');
146
    const code = codeElement.cloneNode(false);
147

  
148
    code.appendChild(range.cloneContents());
149
    pre.appendChild(code);
150

  
151
    return pre;
152
  }
153

  
154
  convertHtmlToCommonMark(html) {
155
    const turndownService = new TurndownService({
156
      codeBlockStyle: 'fenced',
157
      headingStyle: 'atx'
158
    });
159

  
160
    turndownService.addRule('del', {
161
      filter: ['del'],
162
      replacement: content => `~~${content}~~`
163
    });
164

  
165
    turndownService.addRule('checkList', {
166
      filter: node => {
167
        return node.type === 'checkbox' && node.parentNode.nodeName === 'LI';
168
      },
169
      replacement: (content, node) => {
170
        return node.checked ? '[x]' : '[ ]';
171
      }
172
    });
173

  
174
    // Table elements are not formatted, and the within the table is output as is.
175
    //
176
    // | A | B | C |
177
    // |---|---|---|
178
    // | 1 | 2 | 3 |
179
    // =>
180
    // A B C
181
    // 1 2 3
182
    turndownService.addRule('table', {
183
      filter: (node, options) => {
184
        return node.nodeName === 'TD' || node.nodeName === 'TH';
185
      },
186
      replacement: (content, node) => {
187
        const separator = node.parentElement.lastElementChild === node ? '' : ' ';
188
        return content + separator;
189
      }
190
    });
191
    turndownService.addRule('tableHeading', {
192
      filter: ['thead', 'tbody', 'tfoot', 'tr'],
193
      replacement: (content, _node) => content
194
    });
195
    turndownService.addRule('tableRow', {
196
      filter: ['tr'],
197
      replacement: (content, _node) => {
198
        return content + '\n'
199
      }
200
    });
201

  
202
    return turndownService.turndown(html);
203
  }
204

  
205
  prepareHtml(htmlFragment) {
206
    // Remove all anchor elements.
207
    // <h1>Title1<a href="#Title" class="wiki-anchor">¶</a></h1> => <h1>Title1</h1>
208
    htmlFragment.querySelectorAll('a.wiki-anchor').forEach(e => e.remove());
209

  
210
    // Convert code highlight blocks to CommonMark code blocks.
211
    // <code class="ruby" data-language="ruby"> => <code class="language-ruby" data-language="ruby">
212
    htmlFragment.querySelectorAll('code[data-language]').forEach(e => {
213
      e.classList.replace(e.dataset['language'], 'language-' + e.dataset['language'])
214
    });
215

  
216
    return htmlFragment.innerHTML;
217
  }
218
}
app/assets/javascripts/turndown-7.2.0.min.js
1
/*
2
 * Turndown v7.2.0
3
 * https://github.com/mixmark-io/turndown
4
 * Copyright (c) 2017 Dom Christie
5
 * Released under the MIT license
6
 * https://github.com/mixmark-io/turndown/blob/master/LICENSE
7
 */
8
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<e.length;r++){var i=e[r];if(((e,n,t)=>{var r=e.filter;if("string"==typeof r)return r===n.nodeName.toLowerCase();if(Array.isArray(r))return-1<r.indexOf(n.nodeName.toLowerCase());if("function"==typeof r)return!!r.call(e,n,t);throw new TypeError("`filter` needs to be a string, array, or function")})(i,n,t))return i}}function p(e){var n=e.nextSibling||e.parentNode;return e.parentNode.removeChild(e),n}function h(e,n,t){return e&&e.parentNode===n||t(n)?n.nextSibling||n.parentNode:n.firstChild||n.nextSibling||n.parentNode}t.paragraph={filter:"p",replacement:function(e){return"\n\n"+e+"\n\n"}},t.lineBreak={filter:"br",replacement:function(e,n,t){return t.br+"\n"}},t.heading={filter:["h1","h2","h3","h4","h5","h6"],replacement:function(e,n,t){n=Number(n.nodeName.charAt(1));return"setext"===t.headingStyle&&n<3?"\n\n"+e+"\n"+u(1===n?"=":"-",e.length)+"\n\n":"\n\n"+u("#",n)+" "+e+"\n\n"}},t.blockquote={filter:"blockquote",replacement:function(e){return"\n\n"+(e=(e=e.replace(/^\n+|\n+$/g,"")).replace(/^/gm,"> "))+"\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<this.array.length;n++)e(this.array[n],n)}};var g,m="undefined"!=typeof window?window:{},A=(()=>{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('<x-turndown id="turndown-root">'+e+"</x-turndown>","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<arguments.length;n++){var t,r=arguments[n];for(t in r)r.hasOwnProperty(t)&&(e[t]=r[t])}return e}({},{rules:t,headingStyle:"setext",hr:"* * *",bulletListMarker:"*",codeBlockStyle:"indented",fence:"```",emDelimiter:"_",strongDelimiter:"**",linkStyle:"inlined",linkReferenceStyle:"full",br:"  ",preformattedCode:!1,blankReplacement:function(e,n){return n.isBlock?"\n\n":""},keepReplacement:function(e,n){return n.isBlock?"\n\n"+n.outerHTML+"\n\n":n.outerHTML},defaultReplacement:function(e,n){return n.isBlock?"\n\n"+e+"\n\n":e}},e),this.rules=new l(this.options)}function k(e){var r=this;return T.call(e.childNodes,function(e,n){var t="";return 3===(n=new N(n,r.options)).nodeType?t=n.isCode?n.nodeValue:r.escape(n.nodeValue):1===n.nodeType&&(t=function(e){var n=this.rules.forNode(e),t=k.call(this,e),r=e.flankingWhitespace;(r.leading||r.trailing)&&(t=t.trim());return r.leading+n.replacement(t,e,this.options)+r.trailing}.call(r,n)),b(e,t)},"")}function b(e,n){var t=(e=>{for(var n=e.length;0<n&&"\n"===e[n-1];)n--;return e.substring(0,n)})(e),r=n.replace(/^\n*/,""),e=Math.max(e.length-t.length,n.length-r.length);return t+"\n\n".substring(0,e)+r}return C.prototype={turndown:function(e){if(null==(n=e)||"string"!=typeof n&&(!n.nodeType||1!==n.nodeType&&9!==n.nodeType&&11!==n.nodeType))throw new TypeError(e+" is not a string, or an element/document/fragment node.");var n;return""===e?"":(n=k.call(this,new y(e,this.options)),function(n){var t=this;return this.rules.forEach(function(e){"function"==typeof e.append&&(n=b(n,e.append(t.options)))}),n.replace(/^[\t\r\n]+/,"").replace(/[\t\r\n\s]+$/,"")}.call(this,n))},use:function(e){if(Array.isArray(e))for(var n=0;n<e.length;n++)this.use(e[n]);else{if("function"!=typeof e)throw new TypeError("plugin must be a Function or an Array of Functions");e(this)}return this},addRule:function(e,n){return this.rules.add(e,n),this},keep:function(e){return this.rules.keep(e),this},remove:function(e){return this.rules.remove(e),this},escape:function(e){return R.reduce(function(e,n){return e.replace(n[0],n[1])},e)}},C})();
app/views/issues/show.html.erb
1 1
<% content_for :header_tags do %>
2
  <%= javascript_for_quote_reply_include_tag %>
2
  <%= javascripts_for_quote_reply_include_tag %>
3 3
<% end %>
4 4

  
5 5
<%= render :partial => 'action_menu' %>
app/views/messages/show.html.erb
1 1
<% content_for :header_tags do %>
2
  <%= javascript_for_quote_reply_include_tag %>
2
  <%= javascripts_for_quote_reply_include_tag %>
3 3
<% end %>
4 4

  
5 5
<%= board_breadcrumb(@message) %>
lib/redmine/quote_reply.rb
20 20
module Redmine
21 21
  module QuoteReply
22 22
    module Helper
23
      def javascript_for_quote_reply_include_tag
24
        javascript_include_tag 'quote_reply'
23
      def javascripts_for_quote_reply_include_tag
24
        javascript_include_tag 'turndown-7.2.0.min', 'quote_reply'
25 25
      end
26 26

  
27 27
      def quote_reply(url, selector_for_content, icon_only: false)
28
        quote_reply_function = "quoteReply('#{j url}', '#{j selector_for_content}')"
28
        quote_reply_function = "quoteReply('#{j url}', '#{j selector_for_content}', '#{j Setting.text_formatting}')"
29 29

  
30 30
        html_options = { class: 'icon icon-comment' }
31 31
        html_options[:title] = l(:button_quote) if icon_only
test/system/issues_reply_test.rb
27 27
           :watchers, :journals, :journal_details, :versions,
28 28
           :workflows
29 29

  
30
  setup do
31
    log_user('jsmith', 'jsmith')
32
    visit '/issues/1'
33
  end
34

  
35 30
  def test_reply_to_issue
36
    within '.issue.details' do
37
      click_link 'Quote'
38
    end
31
    with_text_formatting 'common_mark' do
32
      within '.issue.details' do
33
        click_link 'Quote'
34
      end
39 35

  
40
    # Select the other than the issue description element.
41
    page.execute_script <<-JS
42
      const range = document.createRange();
43
      // Select "Description" text.
44
      range.selectNodeContents(document.querySelector('.description > p'))
36
      # Select the other than the issue description element.
37
      page.execute_script <<-JS
38
        const range = document.createRange();
39
        // Select "Description" text.
40
        range.selectNodeContents(document.querySelector('.description > p'))
45 41

  
46
      window.getSelection().addRange(range);
47
    JS
42
        window.getSelection().addRange(range);
43
      JS
48 44

  
49
    assert_field 'issue_notes', with: <<~TEXT
50
      John Smith wrote:
51
      > Unable to print recipes
45
      assert_field 'issue_notes', with: <<~TEXT
46
        John Smith wrote:
47
        > Unable to print recipes
52 48

  
53
    TEXT
54
    assert_selector :css, '#issue_notes:focus'
49
      TEXT
50
      assert_selector :css, '#issue_notes:focus'
51
    end
55 52
  end
56 53

  
57 54
  def test_reply_to_note
58
    within '#change-1' do
59
      click_link 'Quote'
60
    end
55
    with_text_formatting 'textile' do
56
      within '#change-1' do
57
        click_link 'Quote'
58
      end
61 59

  
62
    assert_field 'issue_notes', with: <<~TEXT
63
      Redmine Admin wrote in #note-1:
64
      > Journal notes
60
      assert_field 'issue_notes', with: <<~TEXT
61
        Redmine Admin wrote in #note-1:
62
        > Journal notes
65 63

  
66
    TEXT
67
    assert_selector :css, '#issue_notes:focus'
64
      TEXT
65
      assert_selector :css, '#issue_notes:focus'
66
    end
68 67
  end
69 68

  
70 69
  def test_reply_to_issue_with_partial_quote
71
    assert_text 'Unable to print recipes'
70
    with_text_formatting 'common_mark' do
71
      assert_text 'Unable to print recipes'
72 72

  
73
    # Select only the "print" text from the text "Unable to print recipes" in the description.
74
    page.execute_script <<-JS
75
      const range = document.createRange();
76
      const wiki = document.querySelector('#issue_description_wiki > p').childNodes[0];
77
      range.setStart(wiki, 10);
78
      range.setEnd(wiki, 15);
73
      # Select only the "print" text from the text "Unable to print recipes" in the description.
74
      page.execute_script <<-JS
75
        const range = document.createRange();
76
        const wiki = document.querySelector('#issue_description_wiki > p').childNodes[0];
77
        range.setStart(wiki, 10);
78
        range.setEnd(wiki, 15);
79 79

  
80
      window.getSelection().addRange(range);
81
    JS
80
        window.getSelection().addRange(range);
81
      JS
82 82

  
83
    within '.issue.details' do
84
      click_link 'Quote'
85
    end
83
      within '.issue.details' do
84
        click_link 'Quote'
85
      end
86 86

  
87
    assert_field 'issue_notes', with: <<~TEXT
88
      John Smith wrote:
89
      > print
87
      assert_field 'issue_notes', with: <<~TEXT
88
        John Smith wrote:
89
        > print
90 90

  
91
    TEXT
92
    assert_selector :css, '#issue_notes:focus'
91
      TEXT
92
      assert_selector :css, '#issue_notes:focus'
93
    end
93 94
  end
94 95

  
95 96
  def test_reply_to_note_with_partial_quote
96
    assert_text 'Journal notes'
97
    with_text_formatting 'textile' do
98
      assert_text 'Journal notes'
99

  
100
      # Select the entire details of the note#1 and the part of the note#1's text.
101
      page.execute_script <<-JS
102
        const range = document.createRange();
103
        range.setStartBefore(document.querySelector('#change-1 .details'));
104
        // Select only the text "Journal" from the text "Journal notes" in the note-1.
105
        range.setEnd(document.querySelector('#change-1 .wiki > p').childNodes[0], 7);
97 106

  
98
    # Select the entire details of the note#1 and the part of the note#1's text.
99
    page.execute_script <<-JS
100
      const range = document.createRange();
101
      range.setStartBefore(document.querySelector('#change-1 .details'));
102
      // Select only the text "Journal" from the text "Journal notes" in the note-1.
103
      range.setEnd(document.querySelector('#change-1 .wiki > p').childNodes[0], 7);
107
        window.getSelection().addRange(range);
108
      JS
104 109

  
105
      window.getSelection().addRange(range);
106
    JS
110
      within '#change-1' do
111
        click_link 'Quote'
112
      end
107 113

  
108
    within '#change-1' do
109
      click_link 'Quote'
114
      assert_field 'issue_notes', with: <<~TEXT
115
        Redmine Admin wrote in #note-1:
116
        > Journal
117

  
118
      TEXT
119
      assert_selector :css, '#issue_notes:focus'
110 120
    end
121
  end
122

  
123
  def test_partial_quotes_should_be_quoted_in_plain_text_when_text_format_is_textile
124
    issues(:issues_001).update!(description: <<~DESC)
125
      # "Redmine":https://redmine.org is
126
      # a *flexible* project management
127
      # web application.
128
    DESC
129

  
130
    with_text_formatting 'textile' do
131
      assert_text /a flexible project management/
132

  
133
      # Select the entire description of the issue.
134
      page.execute_script <<-JS
135
        const range = document.createRange();
136
        range.selectNodeContents(document.querySelector('#issue_description_wiki'))
137
        window.getSelection().addRange(range);
138
      JS
139

  
140
      within '.issue.details' do
141
        click_link 'Quote'
142
      end
143

  
144
      expected_value = [
145
        'John Smith wrote:',
146
        '> Redmine is',
147
        '> a flexible project management',
148
        '> web application.',
149
        '',
150
        ''
151
      ]
152
      assert_equal expected_value.join("\n"), find_field('issue_notes').value
153
    end
154
  end
111 155

  
112
    assert_field 'issue_notes', with: <<~TEXT
113
      Redmine Admin wrote in #note-1:
114
      > Journal
156
  def test_partial_quotes_should_be_quoted_in_common_mark_format_when_text_format_is_common_mark
157
    issues(:issues_001).update!(description: <<~DESC)
158
      # Title1
159
      [Redmine](https://redmine.org) is a **flexible** project management web application.
115 160

  
116
    TEXT
117
    assert_selector :css, '#issue_notes:focus'
161
      ## Title2
162
      * List1
163
        * List1-1
164
      * List2
165

  
166
      1. Number1
167
      1. Number2
168

  
169
      ### Title3
170
      ```ruby
171
      puts "Hello, world!"
172
      ```
173
      ```
174
      $ bin/rails db:migrate
175
      ```
176

  
177
      | Subject1 | Subject2 |
178
      | -------- | -------- |
179
      | ~~cell1~~| **cell2**|
180

  
181
      * [ ] Checklist1
182
      * [x] Checklist2
183

  
184
      [[WikiPage]]
185
      Issue #14
186
      Issue ##2
187

  
188
      Redmine is `a flexible` project management
189

  
190
      web application.
191
    DESC
192

  
193
    with_text_formatting 'common_mark' do
194
      assert_text /Title1/
195

  
196
      # Select the entire description of the issue.
197
      page.execute_script <<-JS
198
        const range = document.createRange();
199
        range.selectNodeContents(document.querySelector('#issue_description_wiki'))
200
        window.getSelection().addRange(range);
201
      JS
202

  
203
      within '.issue.details' do
204
        click_link 'Quote'
205
      end
206

  
207
      expected_value = [
208
        'John Smith wrote:',
209
        '> # Title1',
210
        '> ',
211
        '> [Redmine](https://redmine.org) is a **flexible** project management web application.',
212
        '> ',
213
        '> ## Title2',
214
        '> ',
215
        '> *   List1',
216
        '>     *   List1-1',
217
        '> *   List2',
218
        '> ',
219
        '> 1.  Number1',
220
        '> 2.  Number2',
221
        '> ',
222
        '> ### Title3',
223
        '> ',
224
        '> ```ruby',
225
        '> puts "Hello, world!"',
226
        '> ```',
227
        '> ',
228
        '> ```',
229
        '> $ bin/rails db:migrate',
230
        '> ```',
231
        '> ',
232
        '> Subject1 Subject2',
233
        '> ~~cell1~~ **cell2**',
234
        '> ',
235
        '> *   [ ] Checklist1',
236
        '> *   [x] Checklist2',
237
        '> ',
238
        '> [WikiPage](/projects/ecookbook/wiki/WikiPage)  ',
239
        '> Issue [#14](/issues/14 "Bug: Private issue on public project (New)")  ',
240
        '> Issue [Feature request #2: Add ingredients categories](/issues/2 "Status: Assigned")',
241
        '> ',
242
        '> Redmine is `a flexible` project management',
243
        '> ',
244
        '> web application.',
245
        '',
246
        ''
247
      ]
248
      assert_equal expected_value.join("\n"), find_field('issue_notes').value
249
    end
250
  end
251

  
252
  private
253

  
254
  def with_text_formatting(format)
255
    with_settings text_formatting: format do
256
      log_user('jsmith', 'jsmith')
257
      visit '/issues/1'
258

  
259
      yield
260
    end
118 261
  end
119 262
end
test/system/messages_test.rb
25 25
           :custom_fields, :custom_values, :custom_fields_trackers,
26 26
           :watchers, :boards, :messages
27 27

  
28
  setup do
29
    log_user('jsmith', 'jsmith')
30
    visit '/boards/1/topics/1'
31
  end
32

  
33 28
  def test_reply_to_topic_message
34
    within '#content > .contextual' do
35
      click_link 'Quote'
36
    end
29
    with_text_formatting 'common_mark' do
30
      within '#content > .contextual' do
31
        click_link 'Quote'
32
      end
37 33

  
38
    assert_field 'message_content', with: <<~TEXT
39
      Redmine Admin wrote:
40
      > This is the very first post
41
      > in the forum
34
      assert_field 'message_content', with: <<~TEXT
35
        Redmine Admin wrote:
36
        > This is the very first post
37
        > in the forum
42 38

  
43
    TEXT
39
      TEXT
40
    end
44 41
  end
45 42

  
46 43
  def test_reply_to_message
47
    within '#message-2' do
48
      click_link 'Quote'
49
    end
44
    with_text_formatting 'textile' do
45
      within '#message-2' do
46
        click_link 'Quote'
47
      end
50 48

  
51
    assert_field 'message_content', with: <<~TEXT
52
      Redmine Admin wrote in message#2:
53
      > Reply to the first post
49
      assert_field 'message_content', with: <<~TEXT
50
        Redmine Admin wrote in message#2:
51
        > Reply to the first post
54 52

  
55
    TEXT
53
      TEXT
54
    end
56 55
  end
57 56

  
58 57
  def test_reply_to_topic_message_with_partial_quote
59
    assert_text /This is the very first post/
58
    with_text_formatting 'textile' do
59
      assert_text /This is the very first post/
60 60

  
61
    # Select the part of the topic message through the entire text of the attachment below it.
62
    page.execute_script <<-'JS'
63
      const range = document.createRange();
64
      const message = document.querySelector('#message_topic_wiki');
65
      // Select only the text "in the forum" from the text "This is the very first post\nin the forum".
66
      range.setStartBefore(message.querySelector('p').childNodes[2]);
67
      range.setEndAfter(message.parentNode.querySelector('.attachments'));
61
      # Select the part of the topic message through the entire text of the attachment below it.
62
      page.execute_script <<-'JS'
63
        const range = document.createRange();
64
        const message = document.querySelector('#message_topic_wiki');
65
        // Select only the text "in the forum" from the text "This is the very first post\nin the forum".
66
        range.setStartBefore(message.querySelector('p').childNodes[2]);
67
        range.setEndAfter(message.parentNode.querySelector('.attachments'));
68 68

  
69
      window.getSelection().addRange(range);
70
    JS
69
        window.getSelection().addRange(range);
70
      JS
71 71

  
72
    within '#content > .contextual' do
73
      click_link 'Quote'
74
    end
72
      within '#content > .contextual' do
73
        click_link 'Quote'
74
      end
75 75

  
76
    assert_field 'message_content', with: <<~TEXT
77
      Redmine Admin wrote:
78
      > in the forum
76
      assert_field 'message_content', with: <<~TEXT
77
        Redmine Admin wrote:
78
        > in the forum
79 79

  
80
    TEXT
80
      TEXT
81
    end
81 82
  end
82 83

  
83 84
  def test_reply_to_message_with_partial_quote
84
    assert_text 'Reply to the first post'
85
    with_text_formatting 'common_mark' do
86
      assert_text 'Reply to the first post'
87

  
88
      # Select the entire message, including the subject and headers of messages #2 and #3.
89
      page.execute_script <<-JS
90
        const range = document.createRange();
91
        range.setStartBefore(document.querySelector('#message-2'));
92
        range.setEndAfter(document.querySelector('#message-3'));
93

  
94
        window.getSelection().addRange(range);
95
      JS
85 96

  
86
    # Select the entire message, including the subject and headers of messages #2 and #3.
87
    page.execute_script <<-JS
88
      const range = document.createRange();
89
      range.setStartBefore(document.querySelector('#message-2'));
90
      range.setEndAfter(document.querySelector('#message-3'));
97
      within '#message-2' do
98
        click_link 'Quote'
99
      end
91 100

  
92
      window.getSelection().addRange(range);
93
    JS
101
      assert_field 'message_content', with: <<~TEXT
102
        Redmine Admin wrote in message#2:
103
        > Reply to the first post
94 104

  
95
    within '#message-2' do
96
      click_link 'Quote'
105
      TEXT
97 106
    end
107
  end
108

  
109
  private
98 110

  
99
    assert_field 'message_content', with: <<~TEXT
100
      Redmine Admin wrote in message#2:
101
      > Reply to the first post
111
  def with_text_formatting(format)
112
    with_settings text_formatting: format do
113
      log_user('jsmith', 'jsmith')
114
      visit '/boards/1/topics/1'
102 115

  
103
    TEXT
116
      yield
117
    end
104 118
  end
105 119
end
test/unit/lib/redmine/quote_reply_helper_test.rb
29 29
    url = quoted_issue_path(issues(:issues_001))
30 30

  
31 31
    a_tag = quote_reply(url, '#issue_description_wiki')
32
    assert_includes a_tag, %|onclick="#{h "quoteReply('/issues/1/quoted', '#issue_description_wiki'); return false;"}"|
32
    assert_includes a_tag, %|onclick="#{h "quoteReply('/issues/1/quoted', '#issue_description_wiki', 'common_mark'); return false;"}"|
33 33
    assert_includes a_tag, %|class="icon icon-comment"|
34 34
    assert_not_includes a_tag, 'title='
35 35

  
(13-13/15)