Patch #42515 » 0002-Reimplement-partial-quote-feature-using-Stimulus.patch
Gemfile | ||
---|---|---|
18 | 18 |
gem 'rack', '>= 3.1.3' |
19 | 19 |
gem "stimulus-rails", "~> 1.3" |
20 | 20 |
gem "importmap-rails", "~> 2.0" |
21 |
gem "requestjs-rails", "~> 0.0.12" |
|
21 | 22 | |
22 | 23 |
# Ruby Standard Gems |
23 | 24 |
gem 'csv', '~> 3.2.8' |
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?"+")":""}},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/helpers/journals_helper.rb | ||
---|---|---|
43 | 43 |
if journal.notes.present? |
44 | 44 |
if options[:reply_links] |
45 | 45 |
url = quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice) |
46 |
links << quote_reply(url, "#journal-#{journal.id}-notes", icon_only: true)
|
|
46 |
links << quote_reply_button(url: url, icon_only: true)
|
|
47 | 47 |
end |
48 | 48 |
if journal.editable_by?(User.current) |
49 | 49 |
links << link_to(sprite_icon('edit', l(:button_edit)), |
... | ... | |
66 | 66 |
end |
67 | 67 | |
68 | 68 |
def render_notes(issue, journal, options={}) |
69 |
content_tag('div', textilizable(journal, :notes), :id => "journal-#{journal.id}-notes", :class => "wiki") |
|
69 |
content_tag('div', textilizable(journal, :notes), |
|
70 |
id: "journal-#{journal.id}-notes", class: "wiki", data: { quote_reply_target: 'content' }) |
|
70 | 71 |
end |
71 | 72 | |
72 | 73 |
def render_private_notes_indicator(journal) |
app/assets/javascripts/quote_reply.js → app/javascript/controllers/quote_reply_controller.js | ||
---|---|---|
1 |
function quoteReply(path, selectorForContentElement, textFormatting) { |
|
2 |
const contentElement = $(selectorForContentElement).get(0); |
|
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 |
} |
|
12 | ||
13 |
$.ajax({ |
|
14 |
url: path, |
|
15 |
type: 'post', |
|
16 |
data: { quote: formatter.format(selectedRange) } |
|
17 |
}); |
|
18 |
} |
|
1 |
import { Controller } from '@hotwired/stimulus' |
|
2 |
import TurndownService from 'turndown' |
|
3 |
import { post } from '@rails/request.js' |
|
19 | 4 | |
20 | 5 |
class QuoteExtractor { |
21 | 6 |
static extract(targetElement) { |
... | ... | |
214 | 199 |
return htmlFragment.innerHTML; |
215 | 200 |
} |
216 | 201 |
} |
202 | ||
203 |
export default class extends Controller { |
|
204 |
static targets = [ 'content' ]; |
|
205 | ||
206 |
quote(event) { |
|
207 |
event.preventDefault(); |
|
208 | ||
209 |
const { url, textFormatting } = event.params; |
|
210 |
const selectedRange = QuoteExtractor.extract(this.contentTarget); |
|
211 | ||
212 |
let formatter; |
|
213 | ||
214 |
if (textFormatting === 'common_mark') { |
|
215 |
formatter = new QuoteCommonMarkFormatter(); |
|
216 |
} else { |
|
217 |
formatter = new QuoteTextFormatter(); |
|
218 |
} |
|
219 | ||
220 |
post(url, { |
|
221 |
body: JSON.stringify({ quote: formatter.format(selectedRange) }), |
|
222 |
contentType: 'application/json', |
|
223 |
responseKind: 'script' |
|
224 |
}); |
|
225 |
} |
|
226 |
} |
app/views/issues/show.html.erb | ||
---|---|---|
1 |
<% content_for :header_tags do %> |
|
2 |
<%= javascripts_for_quote_reply_include_tag %> |
|
3 |
<% end %> |
|
4 | ||
5 | 1 |
<%= render :partial => 'action_menu' %> |
6 | 2 | |
7 | 3 |
<h2 class="inline-block"><%= issue_heading(@issue) %></h2><%= issue_status_type_badge(@issue.status) %> |
... | ... | |
86 | 82 | |
87 | 83 |
<% if @issue.description? %> |
88 | 84 |
<hr /> |
89 |
<div class="description"> |
|
85 |
<div class="description" data-controller="quote-reply">
|
|
90 | 86 |
<div class="contextual"> |
91 |
<%= quote_reply(quoted_issue_path(@issue), '#issue_description_wiki') if @issue.notes_addable? %>
|
|
87 |
<%= quote_reply_button(url: quoted_issue_path(@issue)) if @issue.notes_addable? %>
|
|
92 | 88 |
</div> |
93 | 89 | |
94 | 90 |
<p><strong><%=l(:field_description)%></strong></p> |
95 |
<div id="issue_description_wiki" class="wiki"> |
|
91 |
<div id="issue_description_wiki" class="wiki" data-quote-reply-target="content">
|
|
96 | 92 |
<%= textilizable @issue, :description, :attachments => @issue.attachments %> |
97 | 93 |
</div> |
98 | 94 |
</div> |
app/views/issues/tabs/_history.html.erb | ||
---|---|---|
5 | 5 | |
6 | 6 |
<% reply_links = issue.notes_addable? -%> |
7 | 7 |
<% for journal in journals %> |
8 |
<div id="change-<%= journal.id %>" class="<%= journal.css_classes %>"> |
|
8 |
<div id="change-<%= journal.id %>" class="<%= journal.css_classes %>" data-controller="quote-reply">
|
|
9 | 9 |
<div id="note-<%= journal.indice %>" class="note"> |
10 | 10 |
<div class="contextual"> |
11 | 11 |
<span class="journal-actions"><%= render_journal_actions(issue, journal, :reply_links => reply_links) %></span> |
app/views/messages/show.html.erb | ||
---|---|---|
1 |
<% content_for :header_tags do %> |
|
2 |
<%= javascripts_for_quote_reply_include_tag %> |
|
3 |
<% end %> |
|
4 | ||
5 | 1 |
<%= board_breadcrumb(@message) %> |
6 | 2 | |
7 |
<div class="contextual"> |
|
3 |
<div data-controller="quote-reply"> |
|
4 |
<div class="contextual"> |
|
8 | 5 |
<%= watcher_link(@topic, User.current) %> |
9 |
<%= quote_reply( |
|
10 |
url_for(:action => 'quote', :id => @topic, :format => 'js'), |
|
11 |
'#message_topic_wiki' |
|
6 |
<%= quote_reply_button( |
|
7 |
url: url_for(action: 'quote', id: @topic, format: 'js') |
|
12 | 8 |
) if !@topic.locked? && authorize_for('messages', 'reply') %> |
13 | 9 |
<%= link_to( |
14 | 10 |
sprite_icon('edit', l(:button_edit)), |
... | ... | |
21 | 17 |
:method => :post, |
22 | 18 |
:data => {:confirm => l(:text_are_you_sure)}, |
23 | 19 |
:class => 'icon icon-del' |
24 |
) if @message.destroyable_by?(User.current) %>
|
|
25 |
</div> |
|
20 |
) if @message.destroyable_by?(User.current) %> |
|
21 |
</div>
|
|
26 | 22 | |
27 |
<h2><%= avatar(@topic.author) %><%= @topic.subject %></h2> |
|
23 |
<h2><%= avatar(@topic.author) %><%= @topic.subject %></h2>
|
|
28 | 24 | |
29 |
<div class="message"> |
|
30 |
<p><span class="author"><%= authoring @topic.created_on, @topic.author %></span></p> |
|
31 |
<div id="message_topic_wiki" class="wiki"> |
|
32 |
<%= textilizable(@topic, :content) %> |
|
33 |
</div> |
|
34 |
<%= link_to_attachments @topic, :author => false, :thumbnails => true %> |
|
25 |
<div class="message"> |
|
26 |
<p><span class="author"><%= authoring @topic.created_on, @topic.author %></span></p> |
|
27 |
<div id="message_topic_wiki" class="wiki" data-quote-reply-target="content"> |
|
28 |
<%= textilizable(@topic, :content) %> |
|
29 |
</div> |
|
30 |
<%= link_to_attachments @topic, :author => false, :thumbnails => true %> |
|
31 |
</div> |
|
35 | 32 |
</div> |
36 | 33 |
<br /> |
37 | 34 | |
... | ... | |
42 | 39 |
<p><%= toggle_link l(:button_reply), "reply", :focus => 'message_content', :scroll => "message_content" %></p> |
43 | 40 |
<% end %> |
44 | 41 |
<% @replies.each do |message| %> |
45 |
<div class="message reply" id="<%= "message-#{message.id}" %>"> |
|
42 |
<div class="message reply" id="<%= "message-#{message.id}" %>" data-controller="quote-reply">
|
|
46 | 43 |
<div class="contextual"> |
47 |
<%= quote_reply( |
|
48 |
url_for(:action => 'quote', :id => message, :format => 'js'), |
|
49 |
"#message-#{message.id} .wiki", |
|
44 |
<%= quote_reply_button( |
|
45 |
url: url_for(action: 'quote', id: message, format: 'js'), |
|
50 | 46 |
icon_only: true |
51 | 47 |
) if !@topic.locked? && authorize_for('messages', 'reply') %> |
52 | 48 |
<%= link_to( |
... | ... | |
70 | 66 |
- |
71 | 67 |
<%= authoring message.created_on, message.author %> |
72 | 68 |
</h4> |
73 |
<div class="wiki"><%= textilizable message, :content, :attachments => message.attachments %></div> |
|
69 |
<div class="wiki" data-quote-reply-target="content"> |
|
70 |
<%= textilizable message, :content, :attachments => message.attachments %> |
|
71 |
</div> |
|
74 | 72 |
<%= link_to_attachments message, :author => false, :thumbnails => true %> |
75 | 73 |
</div> |
76 | 74 |
<% end %> |
config/importmap.rb | ||
---|---|---|
4 | 4 |
pin "@hotwired/stimulus", to: "stimulus.min.js" |
5 | 5 |
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" |
6 | 6 |
pin_all_from "app/javascript/controllers", under: "controllers" |
7 |
pin "turndown" # @7.2.0 |
lib/redmine/quote_reply.rb | ||
---|---|---|
20 | 20 |
module Redmine |
21 | 21 |
module QuoteReply |
22 | 22 |
module Helper |
23 |
def javascripts_for_quote_reply_include_tag |
|
24 |
javascript_include_tag 'turndown-7.2.0.min', 'quote_reply' |
|
25 |
end |
|
26 | ||
27 |
def quote_reply(url, selector_for_content, icon_only: false) |
|
28 |
quote_reply_function = "quoteReply('#{j url}', '#{j selector_for_content}', '#{j Setting.text_formatting}')" |
|
23 |
def quote_reply_button(url:, icon_only: false) |
|
24 |
button_params = { |
|
25 |
data: { |
|
26 |
action: 'quote-reply#quote', |
|
27 |
quote_reply_url_param: url, |
|
28 |
quote_reply_text_formatting_param: Setting.text_formatting |
|
29 |
}, |
|
30 |
class: 'icon icon-comment' |
|
31 |
} |
|
32 |
button_params[:title] = l(:button_quote) if icon_only |
|
29 | 33 | |
30 |
html_options = { class: 'icon icon-comment' } |
|
31 |
html_options[:title] = l(:button_quote) if icon_only |
|
32 | ||
33 |
link_to_function( |
|
34 |
sprite_icon('comment', l(:button_quote), icon_only: icon_only), |
|
35 |
quote_reply_function, |
|
36 |
html_options |
|
37 |
) |
|
34 |
link_to sprite_icon('comment', l(:button_quote), icon_only: icon_only), '#', button_params |
|
38 | 35 |
end |
39 | 36 |
end |
40 | 37 |
test/system/messages_test.rb | ||
---|---|---|
22 | 22 |
class MessagesTest < ApplicationSystemTestCase |
23 | 23 |
def test_reply_to_topic_message |
24 | 24 |
with_text_formatting 'common_mark' do |
25 |
within '#content > .contextual' do
|
|
25 |
within '#content > [data-controller="quote-reply"]' do
|
|
26 | 26 |
click_link 'Quote' |
27 | 27 |
end |
28 | 28 | |
... | ... | |
64 | 64 |
window.getSelection().addRange(range); |
65 | 65 |
JS |
66 | 66 | |
67 |
within '#content > .contextual' do
|
|
67 |
within '#content > [data-controller="quote-reply"]' do
|
|
68 | 68 |
click_link 'Quote' |
69 | 69 |
end |
70 | 70 |
test/unit/lib/redmine/quote_reply_helper_test.rb | ||
---|---|---|
23 | 23 |
include ERB::Util |
24 | 24 |
include Redmine::QuoteReply::Helper |
25 | 25 | |
26 |
def test_quote_reply |
|
26 |
def test_quote_reply_button
|
|
27 | 27 |
with_locale 'en' do |
28 | 28 |
url = quoted_issue_path(issues(:issues_001)) |
29 | 29 | |
30 |
a_tag = quote_reply(url, '#issue_description_wiki')
|
|
31 |
assert_includes a_tag, %|onclick="#{h "quoteReply('/issues/1/quoted', '#issue_description_wiki', 'common_mark'); return false;"}"|
|
|
32 |
assert_includes a_tag, %|class="icon icon-comment"|
|
|
33 |
assert_not_includes a_tag, 'title='
|
|
30 |
html = quote_reply_button(url: url)
|
|
31 |
assert_select_in html,
|
|
32 |
'a[data-quote-reply-url-param=?][data-quote-reply-text-formatting-param=?]:not([title])',
|
|
33 |
url, Setting.text_formatting
|
|
34 | 34 | |
35 | 35 |
# When icon_only is true |
36 |
a_tag = quote_reply(url, '#issue_description_wiki', icon_only: true)
|
|
37 |
assert_includes a_tag, %|title="Quote"|
|
|
36 |
html = quote_reply_button(url: url, icon_only: true)
|
|
37 |
assert_select_in html, 'a.icon.icon-comment[title=?]', 'Quote'
|
|
38 | 38 |
end |
39 | 39 |
end |
40 | 40 |
end |
vendor/javascript/turndown.js | ||
---|---|---|
1 |
// turndown@7.2.0 downloaded from https://ga.jspm.io/npm:turndown@7.2.0/lib/turndown.browser.es.js |
|
2 | ||
3 |
function extend(e){for(var n=1;n<arguments.length;n++){var t=arguments[n];for(var r in t)t.hasOwnProperty(r)&&(e[r]=t[r])}return e}function repeat(e,n){return Array(n+1).join(e)}function trimLeadingNewlines(e){return e.replace(/^\n*/,"")}function trimTrailingNewlines(e){var n=e.length;while(n>0&&e[n-1]==="\n")n--;return e.substring(0,n)}var e=["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 isBlock(n){return is(n,e)}var n=["AREA","BASE","BR","COL","COMMAND","EMBED","HR","IMG","INPUT","KEYGEN","LINK","META","PARAM","SOURCE","TRACK","WBR"];function isVoid(e){return is(e,n)}function hasVoid(e){return has(e,n)}var t=["A","TABLE","THEAD","TBODY","TFOOT","TH","TD","IFRAME","SCRIPT","AUDIO","VIDEO"];function isMeaningfulWhenBlank(e){return is(e,t)}function hasMeaningfulWhenBlank(e){return has(e,t)}function is(e,n){return n.indexOf(e.nodeName)>=0}function has(e,n){return e.getElementsByTagName&&n.some((function(n){return e.getElementsByTagName(n).length}))}var r={};r.paragraph={filter:"p",replacement:function(e){return"\n\n"+e+"\n\n"}};r.lineBreak={filter:"br",replacement:function(e,n,t){return t.br+"\n"}};r.heading={filter:["h1","h2","h3","h4","h5","h6"],replacement:function(e,n,t){var r=Number(n.nodeName.charAt(1));if(t.headingStyle==="setext"&&r<3){var i=repeat(r===1?"=":"-",e.length);return"\n\n"+e+"\n"+i+"\n\n"}return"\n\n"+repeat("#",r)+" "+e+"\n\n"}};r.blockquote={filter:"blockquote",replacement:function(e){e=e.replace(/^\n+|\n+$/g,"");e=e.replace(/^/gm,"> ");return"\n\n"+e+"\n\n"}};r.list={filter:["ul","ol"],replacement:function(e,n){var t=n.parentNode;return t.nodeName==="LI"&&t.lastElementChild===n?"\n"+e:"\n\n"+e+"\n\n"}};r.listItem={filter:"li",replacement:function(e,n,t){e=e.replace(/^\n+/,"").replace(/\n+$/,"\n").replace(/\n/gm,"\n ");var r=t.bulletListMarker+" ";var i=n.parentNode;if(i.nodeName==="OL"){var a=i.getAttribute("start");var o=Array.prototype.indexOf.call(i.children,n);r=(a?Number(a)+o:o+1)+". "}return r+e+(n.nextSibling&&!/\n$/.test(e)?"\n":"")}};r.indentedCodeBlock={filter:function(e,n){return n.codeBlockStyle==="indented"&&e.nodeName==="PRE"&&e.firstChild&&e.firstChild.nodeName==="CODE"},replacement:function(e,n,t){return"\n\n "+n.firstChild.textContent.replace(/\n/g,"\n ")+"\n\n"}};r.fencedCodeBlock={filter:function(e,n){return n.codeBlockStyle==="fenced"&&e.nodeName==="PRE"&&e.firstChild&&e.firstChild.nodeName==="CODE"},replacement:function(e,n,t){var r=n.firstChild.getAttribute("class")||"";var i=(r.match(/language-(\S+)/)||[null,""])[1];var a=n.firstChild.textContent;var o=t.fence.charAt(0);var l=3;var u=new RegExp("^"+o+"{3,}","gm");var s;while(s=u.exec(a))s[0].length>=l&&(l=s[0].length+1);var c=repeat(o,l);return"\n\n"+c+i+"\n"+a.replace(/\n$/,"")+"\n"+c+"\n\n"}};r.horizontalRule={filter:"hr",replacement:function(e,n,t){return"\n\n"+t.hr+"\n\n"}};r.inlineLink={filter:function(e,n){return n.linkStyle==="inlined"&&e.nodeName==="A"&&e.getAttribute("href")},replacement:function(e,n){var t=n.getAttribute("href");t&&(t=t.replace(/([()])/g,"\\$1"));var r=cleanAttribute(n.getAttribute("title"));r&&(r=' "'+r.replace(/"/g,'\\"')+'"');return"["+e+"]("+t+r+")"}};r.referenceLink={filter:function(e,n){return n.linkStyle==="referenced"&&e.nodeName==="A"&&e.getAttribute("href")},replacement:function(e,n,t){var r=n.getAttribute("href");var i=cleanAttribute(n.getAttribute("title"));i&&(i=' "'+i+'"');var a;var o;switch(t.linkReferenceStyle){case"collapsed":a="["+e+"][]";o="["+e+"]: "+r+i;break;case"shortcut":a="["+e+"]";o="["+e+"]: "+r+i;break;default:var l=this.references.length+1;a="["+e+"]["+l+"]";o="["+l+"]: "+r+i}this.references.push(o);return a},references:[],append:function(e){var n="";if(this.references.length){n="\n\n"+this.references.join("\n")+"\n\n";this.references=[]}return n}};r.emphasis={filter:["em","i"],replacement:function(e,n,t){return e.trim()?t.emDelimiter+e+t.emDelimiter:""}};r.strong={filter:["strong","b"],replacement:function(e,n,t){return e.trim()?t.strongDelimiter+e+t.strongDelimiter:""}};r.code={filter:function(e){var n=e.previousSibling||e.nextSibling;var t=e.parentNode.nodeName==="PRE"&&!n;return e.nodeName==="CODE"&&!t},replacement:function(e){if(!e)return"";e=e.replace(/\r?\n|\r/g," ");var n=/^`|^ .*?[^ ].* $|`$/.test(e)?" ":"";var t="`";var r=e.match(/`+/gm)||[];while(r.indexOf(t)!==-1)t+="`";return t+n+e+n+t}};r.image={filter:"img",replacement:function(e,n){var t=cleanAttribute(n.getAttribute("alt"));var r=n.getAttribute("src")||"";var i=cleanAttribute(n.getAttribute("title"));var a=i?' "'+i+'"':"";return r?"":""}};function cleanAttribute(e){return e?e.replace(/(\n+\s*)+/g,"\n"):""}function Rules(e){this.options=e;this._keep=[];this._remove=[];this.blankRule={replacement:e.blankReplacement};this.keepReplacement=e.keepReplacement;this.defaultRule={replacement:e.defaultReplacement};this.array=[];for(var n in e.rules)this.array.push(e.rules[n])}Rules.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:(n=findRule(this.array,e,this.options))||(n=findRule(this._keep,e,this.options))||(n=findRule(this._remove,e,this.options))?n:this.defaultRule;var n},forEach:function(e){for(var n=0;n<this.array.length;n++)e(this.array[n],n)}};function findRule(e,n,t){for(var r=0;r<e.length;r++){var i=e[r];if(filterValue(i,n,t))return i}}function filterValue(e,n,t){var r=e.filter;if(typeof r==="string"){if(r===n.nodeName.toLowerCase())return true}else if(Array.isArray(r)){if(r.indexOf(n.nodeName.toLowerCase())>-1)return true}else{if(typeof r!=="function")throw new TypeError("`filter` needs to be a string, array, or function");if(r.call(e,n,t))return true}} |
|
4 |
/** |
|
5 |
* collapseWhitespace(options) removes extraneous whitespace from an the given element. |
|
6 |
* |
|
7 |
* @param {Object} options |
|
8 |
*/function collapseWhitespace(e){var n=e.element;var t=e.isBlock;var r=e.isVoid;var i=e.isPre||function(e){return e.nodeName==="PRE"};if(n.firstChild&&!i(n)){var a=null;var o=false;var l=null;var u=next(l,n,i);while(u!==n){if(u.nodeType===3||u.nodeType===4){var s=u.data.replace(/[ \r\n\t]+/g," ");a&&!/ $/.test(a.data)||o||s[0]!==" "||(s=s.substr(1));if(!s){u=remove(u);continue}u.data=s;a=u}else{if(u.nodeType!==1){u=remove(u);continue}if(t(u)||u.nodeName==="BR"){a&&(a.data=a.data.replace(/ $/,""));a=null;o=false}else if(r(u)||i(u)){a=null;o=true}else a&&(o=false)}var c=next(l,u,i);l=u;u=c}if(a){a.data=a.data.replace(/ $/,"");a.data||remove(a)}}} |
|
9 |
/** |
|
10 |
* remove(node) removes the given node from the DOM and returns the |
|
11 |
* next node in the sequence. |
|
12 |
* |
|
13 |
* @param {Node} node |
|
14 |
* @return {Node} node |
|
15 |
*/function remove(e){var n=e.nextSibling||e.parentNode;e.parentNode.removeChild(e);return n} |
|
16 |
/** |
|
17 |
* next(prev, current, isPre) returns the next node in the sequence, given the |
|
18 |
* current and previous nodes. |
|
19 |
* |
|
20 |
* @param {Node} prev |
|
21 |
* @param {Node} current |
|
22 |
* @param {Function} isPre |
|
23 |
* @return {Node} |
|
24 |
*/function next(e,n,t){return e&&e.parentNode===n||t(n)?n.nextSibling||n.parentNode:n.firstChild||n.nextSibling||n.parentNode}var i=typeof window!=="undefined"?window:{};function canParseHTMLNatively(){var e=i.DOMParser;var n=false;try{(new e).parseFromString("","text/html")&&(n=true)}catch(e){}return n}function createHTMLParser(){var Parser=function(){};shouldUseActiveX()?Parser.prototype.parseFromString=function(e){var n=new window.ActiveXObject("htmlfile");n.designMode="on";n.open();n.write(e);n.close();return n}:Parser.prototype.parseFromString=function(e){var n=document.implementation.createHTMLDocument("");n.open();n.write(e);n.close();return n};return Parser}function shouldUseActiveX(){var e=false;try{document.implementation.createHTMLDocument("").open()}catch(n){i.ActiveXObject&&(e=true)}return e}var a=canParseHTMLNatively()?i.DOMParser:createHTMLParser();function RootNode(e,n){var t;if(typeof e==="string"){var r=htmlParser().parseFromString('<x-turndown id="turndown-root">'+e+"</x-turndown>","text/html");t=r.getElementById("turndown-root")}else t=e.cloneNode(true);collapseWhitespace({element:t,isBlock:isBlock,isVoid:isVoid,isPre:n.preformattedCode?isPreOrCode:null});return t}var o;function htmlParser(){o=o||new a;return o}function isPreOrCode(e){return e.nodeName==="PRE"||e.nodeName==="CODE"}function Node(e,n){e.isBlock=isBlock(e);e.isCode=e.nodeName==="CODE"||e.parentNode.isCode;e.isBlank=isBlank(e);e.flankingWhitespace=flankingWhitespace(e,n);return e}function isBlank(e){return!isVoid(e)&&!isMeaningfulWhenBlank(e)&&/^\s*$/i.test(e.textContent)&&!hasVoid(e)&&!hasMeaningfulWhenBlank(e)}function flankingWhitespace(e,n){if(e.isBlock||n.preformattedCode&&e.isCode)return{leading:"",trailing:""};var t=edgeWhitespace(e.textContent);t.leadingAscii&&isFlankedByWhitespace("left",e,n)&&(t.leading=t.leadingNonAscii);t.trailingAscii&&isFlankedByWhitespace("right",e,n)&&(t.trailing=t.trailingNonAscii);return{leading:t.leading,trailing:t.trailing}}function edgeWhitespace(e){var n=e.match(/^(([ \t\r\n]*)(\s*))(?:(?=\S)[\s\S]*\S)?((\s*?)([ \t\r\n]*))$/);return{leading:n[1],leadingAscii:n[2],leadingNonAscii:n[3],trailing:n[4],trailingNonAscii:n[5],trailingAscii:n[6]}}function isFlankedByWhitespace(e,n,t){var r;var i;var a;if(e==="left"){r=n.previousSibling;i=/ $/}else{r=n.nextSibling;i=/^ /}r&&(r.nodeType===3?a=i.test(r.nodeValue):t.preformattedCode&&r.nodeName==="CODE"?a=false:r.nodeType!==1||isBlock(r)||(a=i.test(r.textContent)));return a}var l=Array.prototype.reduce;var u=[[/\\/g,"\\\\"],[/\*/g,"\\*"],[/^-/g,"\\-"],[/^\+ /g,"\\+ "],[/^(=+)/g,"\\$1"],[/^(#{1,6}) /g,"\\$1 "],[/`/g,"\\`"],[/^~~~/g,"\\~~~"],[/\[/g,"\\["],[/\]/g,"\\]"],[/^>/g,"\\>"],[/_/g,"\\_"],[/^(\d+)\. /g,"$1\\. "]];function TurndownService(e){if(!(this instanceof TurndownService))return new TurndownService(e);var n={rules:r,headingStyle:"setext",hr:"* * *",bulletListMarker:"*",codeBlockStyle:"indented",fence:"```",emDelimiter:"_",strongDelimiter:"**",linkStyle:"inlined",linkReferenceStyle:"full",br:" ",preformattedCode:false,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}};this.options=extend({},n,e);this.rules=new Rules(this.options)}TurndownService.prototype={ |
|
25 |
/** |
|
26 |
* The entry point for converting a string or DOM node to Markdown |
|
27 |
* @public |
|
28 |
* @param {String|HTMLElement} input The string or DOM node to convert |
|
29 |
* @returns A Markdown representation of the input |
|
30 |
* @type String |
|
31 |
*/ |
|
32 |
turndown:function(e){if(!canConvert(e))throw new TypeError(e+" is not a string, or an element/document/fragment node.");if(e==="")return"";var n=process.call(this,new RootNode(e,this.options));return postProcess.call(this,n)}, |
|
33 |
/** |
|
34 |
* Add one or more plugins |
|
35 |
* @public |
|
36 |
* @param {Function|Array} plugin The plugin or array of plugins to add |
|
37 |
* @returns The Turndown instance for chaining |
|
38 |
* @type Object |
|
39 |
*/ |
|
40 |
use:function(e){if(Array.isArray(e))for(var n=0;n<e.length;n++)this.use(e[n]);else{if(typeof e!=="function")throw new TypeError("plugin must be a Function or an Array of Functions");e(this)}return this}, |
|
41 |
/** |
|
42 |
* Adds a rule |
|
43 |
* @public |
|
44 |
* @param {String} key The unique key of the rule |
|
45 |
* @param {Object} rule The rule |
|
46 |
* @returns The Turndown instance for chaining |
|
47 |
* @type Object |
|
48 |
*/ |
|
49 |
addRule:function(e,n){this.rules.add(e,n);return this}, |
|
50 |
/** |
|
51 |
* Keep a node (as HTML) that matches the filter |
|
52 |
* @public |
|
53 |
* @param {String|Array|Function} filter The unique key of the rule |
|
54 |
* @returns The Turndown instance for chaining |
|
55 |
* @type Object |
|
56 |
*/ |
|
57 |
keep:function(e){this.rules.keep(e);return this}, |
|
58 |
/** |
|
59 |
* Remove a node that matches the filter |
|
60 |
* @public |
|
61 |
* @param {String|Array|Function} filter The unique key of the rule |
|
62 |
* @returns The Turndown instance for chaining |
|
63 |
* @type Object |
|
64 |
*/ |
|
65 |
remove:function(e){this.rules.remove(e);return this}, |
|
66 |
/** |
|
67 |
* Escapes Markdown syntax |
|
68 |
* @public |
|
69 |
* @param {String} string The string to escape |
|
70 |
* @returns A string with Markdown syntax escaped |
|
71 |
* @type String |
|
72 |
*/ |
|
73 |
escape:function(e){return u.reduce((function(e,n){return e.replace(n[0],n[1])}),e)}}; |
|
74 |
/** |
|
75 |
* Reduces a DOM node down to its Markdown string equivalent |
|
76 |
* @private |
|
77 |
* @param {HTMLElement} parentNode The node to convert |
|
78 |
* @returns A Markdown representation of the node |
|
79 |
* @type String |
|
80 |
*/function process(e){var n=this;return l.call(e.childNodes,(function(e,t){t=new Node(t,n.options);var r="";t.nodeType===3?r=t.isCode?t.nodeValue:n.escape(t.nodeValue):t.nodeType===1&&(r=replacementForNode.call(n,t));return join(e,r)}),"")} |
|
81 |
/** |
|
82 |
* Appends strings as each rule requires and trims the output |
|
83 |
* @private |
|
84 |
* @param {String} output The conversion output |
|
85 |
* @returns A trimmed version of the ouput |
|
86 |
* @type String |
|
87 |
*/function postProcess(e){var n=this;this.rules.forEach((function(t){typeof t.append==="function"&&(e=join(e,t.append(n.options)))}));return e.replace(/^[\t\r\n]+/,"").replace(/[\t\r\n\s]+$/,"")} |
|
88 |
/** |
|
89 |
* Converts an element node to its Markdown equivalent |
|
90 |
* @private |
|
91 |
* @param {HTMLElement} node The node to convert |
|
92 |
* @returns A Markdown representation of the node |
|
93 |
* @type String |
|
94 |
*/function replacementForNode(e){var n=this.rules.forNode(e);var t=process.call(this,e);var r=e.flankingWhitespace;(r.leading||r.trailing)&&(t=t.trim());return r.leading+n.replacement(t,e,this.options)+r.trailing} |
|
95 |
/** |
|
96 |
* Joins replacement to the current output with appropriate number of new lines |
|
97 |
* @private |
|
98 |
* @param {String} output The current conversion output |
|
99 |
* @param {String} replacement The string to append to the output |
|
100 |
* @returns Joined output |
|
101 |
* @type String |
|
102 |
*/function join(e,n){var t=trimTrailingNewlines(e);var r=trimLeadingNewlines(n);var i=Math.max(e.length-t.length,n.length-r.length);var a="\n\n".substring(0,i);return t+a+r} |
|
103 |
/** |
|
104 |
* Determines whether an input can be converted |
|
105 |
* @private |
|
106 |
* @param {String|HTMLElement} input Describe this parameter |
|
107 |
* @returns Describe what it returns |
|
108 |
* @type String|Object|Array|Boolean|Number |
|
109 |
*/function canConvert(e){return e!=null&&(typeof e==="string"||e.nodeType&&(e.nodeType===1||e.nodeType===9||e.nodeType===11))}export{TurndownService as default}; |
|
110 |