mirror of
https://github.com/redmine/redmine.git
synced 2026-02-22 14:37:58 +01:00
Add stimulus clipboard_controller and render copy button on the backend using copypre_scrubber (#43643).
Patch by Takashi Kato (user:tohosaku). git-svn-id: https://svn.redmine.org/redmine/trunk@24360 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
@@ -1441,4 +1441,3 @@ $(document).ready(setupFilePreviewNavigation);
|
||||
$(document).on('focus', '[data-auto-complete=true]', function(event) {
|
||||
inlineAutoComplete(event.target);
|
||||
});
|
||||
document.addEventListener("DOMContentLoaded", () => { setupCopyButtonsToPreElements(); });
|
||||
|
||||
@@ -1923,11 +1923,10 @@ module ApplicationHelper
|
||||
end
|
||||
|
||||
def copy_object_url_link(url)
|
||||
link_to_function(
|
||||
sprite_icon('copy-link', l(:button_copy_link)), 'copyDataClipboardTextToClipboard(this);',
|
||||
class: 'icon icon-copy-link',
|
||||
data: {'clipboard-text' => url}
|
||||
)
|
||||
link_to sprite_icon('copy-link', l(:button_copy_link)),
|
||||
'#',
|
||||
class: 'icon icon-copy-link',
|
||||
data: {clipboard_text: url, controller: 'clipboard', action: 'clipboard#copyText'}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
55
app/javascript/controllers/clipboard_controller.js
Normal file
55
app/javascript/controllers/clipboard_controller.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Redmine - project management software
|
||||
* Copyright (C) 2006- Jean-Philippe Lang
|
||||
* This code is released under the GNU General Public License.
|
||||
*/
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Connects to data-controller="clipboard"
|
||||
export default class extends Controller {
|
||||
static targets = ['pre'];
|
||||
|
||||
copyPre(e) {
|
||||
e.preventDefault();
|
||||
const element = e.currentTarget;
|
||||
let textToCopy = (this.preTarget.querySelector("code") || this.preTarget).textContent.replace(/\n$/, '');
|
||||
if (this.preTarget.querySelector("code.syntaxhl")) { textToCopy = textToCopy.replace(/ $/, ''); } // Workaround for half-width space issue in Textile's highlighted code
|
||||
|
||||
this.copy(textToCopy).then(() => {
|
||||
updateSVGIcon(element, "checked");
|
||||
setTimeout(() => updateSVGIcon(element, "copy-pre-content"), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
copyText(e) {
|
||||
e.preventDefault();
|
||||
this.copy(e.currentTarget.dataset.clipboardText);
|
||||
|
||||
const element = e.currentTarget.closest('.drdn.expanded');
|
||||
if (element !== null) {
|
||||
element.classList.remove('expanded');
|
||||
}
|
||||
}
|
||||
|
||||
copy(text) {
|
||||
if (navigator.clipboard) {
|
||||
return navigator.clipboard.writeText(text).catch(() => {
|
||||
return this.fallback(text);
|
||||
});
|
||||
} else {
|
||||
return this.fallback(text);
|
||||
}
|
||||
}
|
||||
|
||||
fallback(text) {
|
||||
const temp = document.createElement('textarea');
|
||||
temp.value = text;
|
||||
temp.style.position = 'fixed';
|
||||
temp.style.left = '-9999px';
|
||||
document.body.appendChild(temp);
|
||||
temp.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(temp);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@
|
||||
} else {
|
||||
journal_header.append('<%= escape_javascript(render_journal_update_info(@journal)) %>');
|
||||
}
|
||||
setupCopyButtonsToPreElements();
|
||||
setupHoverTooltips();
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ module Redmine
|
||||
SCRUBBERS = [
|
||||
SyntaxHighlightScrubber.new,
|
||||
Redmine::WikiFormatting::TablesortScrubber.new,
|
||||
Redmine::WikiFormatting::CopypreScrubber.new,
|
||||
FixupAutoLinksScrubber.new,
|
||||
ExternalLinksScrubber.new,
|
||||
AlertsIconsScrubber.new
|
||||
|
||||
33
lib/redmine/wiki_formatting/copypre_scrubber.rb
Normal file
33
lib/redmine/wiki_formatting/copypre_scrubber.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Redmine - project management software
|
||||
# Copyright (C) 2006- Jean-Philippe Lang
|
||||
# This code is released under the GNU General Public License.
|
||||
|
||||
module Redmine
|
||||
module WikiFormatting
|
||||
class CopypreScrubber < Loofah::Scrubber
|
||||
def scrub(node)
|
||||
return unless node.name == 'pre'
|
||||
|
||||
node['data-clipboard-target'] = 'pre'
|
||||
# Wrap the <pre> element with a container and add a copy button
|
||||
node.wrap(wrapper)
|
||||
|
||||
# Copy the contents of the pre tag when copyButton is clicked
|
||||
node.parent.prepend_child(button)
|
||||
end
|
||||
|
||||
def wrapper
|
||||
@wrapper ||= Nokogiri::HTML5.fragment('<div class="pre-wrapper" data-controller="clipboard"></div>').children.first
|
||||
end
|
||||
|
||||
def button
|
||||
icon = ApplicationController.helpers.sprite_icon('copy-pre-content', size: 18)
|
||||
button_copy = ApplicationController.helpers.l(:button_copy)
|
||||
html = '<a class="copy-pre-content-link icon-only" title="' + button_copy + '" data-action="clipboard#copyPre">' + icon + '</a>'
|
||||
@button ||= Nokogiri::HTML5.fragment(html).children.first
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -22,7 +22,8 @@ module Redmine
|
||||
module Textile
|
||||
SCRUBBERS = [
|
||||
SyntaxHighlightScrubber.new,
|
||||
Redmine::WikiFormatting::TablesortScrubber.new
|
||||
Redmine::WikiFormatting::TablesortScrubber.new,
|
||||
Redmine::WikiFormatting::CopypreScrubber.new
|
||||
]
|
||||
|
||||
class Formatter
|
||||
|
||||
@@ -1320,20 +1320,20 @@ class ApplicationHelperTest < Redmine::HelperTest
|
||||
"<div class=\"bold\">content</div>" => "<p><div class=\"bold\">content</div></p>",
|
||||
"<script>some script;</script>" => "<p><script>some script;</script></p>",
|
||||
# do not escape pre/code tags
|
||||
"<pre>\nline 1\nline2</pre>" => "<pre>line 1\nline2</pre>",
|
||||
"<pre><code>\nline 1\nline2</code></pre>" => "<pre><code>\nline 1\nline2</code></pre>",
|
||||
"<pre><div class=\"foo\">content</div></pre>" => "<pre><div class=\"foo\">content</div></pre>",
|
||||
"<pre><div class=\"<foo\">content</div></pre>" => "<pre><div class=\"<foo\">content</div></pre>",
|
||||
"<pre>\nline 1\nline2</pre>" => pre_wrapper("<pre data-clipboard-target=\"pre\">line 1\nline2</pre>"),
|
||||
"<pre><code>\nline 1\nline2</code></pre>" => pre_wrapper("<pre data-clipboard-target=\"pre\"><code>\nline 1\nline2</code></pre>"),
|
||||
"<pre><div class=\"foo\">content</div></pre>" => pre_wrapper("<pre data-clipboard-target=\"pre\"><div class=\"foo\">content</div></pre>"),
|
||||
"<pre><div class=\"<foo\">content</div></pre>" => pre_wrapper("<pre data-clipboard-target=\"pre\"><div class=\"<foo\">content</div></pre>"),
|
||||
"<!-- opening comment" => "<p><!-- opening comment</p>",
|
||||
# remove attributes including class
|
||||
"<pre class='foo'>some text</pre>" => "<pre>some text</pre>",
|
||||
'<pre class="foo">some text</pre>' => '<pre>some text</pre>',
|
||||
"<pre class='foo bar'>some text</pre>" => "<pre>some text</pre>",
|
||||
'<pre class="foo bar">some text</pre>' => '<pre>some text</pre>',
|
||||
"<pre onmouseover='alert(1)'>some text</pre>" => "<pre>some text</pre>",
|
||||
"<pre class='foo'>some text</pre>" => pre_wrapper('<pre data-clipboard-target="pre">some text</pre>'),
|
||||
'<pre class="foo">some text</pre>' => pre_wrapper('<pre data-clipboard-target="pre">some text</pre>'),
|
||||
"<pre class='foo bar'>some text</pre>" => pre_wrapper('<pre data-clipboard-target="pre">some text</pre>'),
|
||||
'<pre class="foo bar">some text</pre>' => pre_wrapper('<pre data-clipboard-target="pre">some text</pre>'),
|
||||
"<pre onmouseover='alert(1)'>some text</pre>" => pre_wrapper('<pre data-clipboard-target="pre">some text</pre>'),
|
||||
# xss
|
||||
'<pre><code class=""onmouseover="alert(1)">text</code></pre>' => '<pre><code>text</code></pre>',
|
||||
'<pre class=""onmouseover="alert(1)">text</pre>' => '<pre>text</pre>',
|
||||
'<pre><code class=""onmouseover="alert(1)">text</code></pre>' => pre_wrapper('<pre data-clipboard-target="pre"><code>text</code></pre>'),
|
||||
'<pre class=""onmouseover="alert(1)">text</pre>' => pre_wrapper('<pre data-clipboard-target="pre">text</pre>'),
|
||||
}
|
||||
with_settings :text_formatting => 'textile' do
|
||||
to_test.each {|text, result| assert_equal result, textilizable(text)}
|
||||
@@ -1342,7 +1342,7 @@ class ApplicationHelperTest < Redmine::HelperTest
|
||||
|
||||
def test_allowed_html_tags
|
||||
to_test = {
|
||||
"<pre>preformatted text</pre>" => "<pre>preformatted text</pre>",
|
||||
"<pre>preformatted text</pre>" => pre_wrapper('<pre data-clipboard-target="pre">preformatted text</pre>'),
|
||||
"<notextile>no *textile* formatting</notextile>" => "no *textile* formatting",
|
||||
"<notextile>this is <tag>a tag</tag></notextile>" => "this is <tag>a tag</tag>"
|
||||
}
|
||||
@@ -1363,9 +1363,7 @@ class ApplicationHelperTest < Redmine::HelperTest
|
||||
RAW
|
||||
expected = <<~EXPECTED
|
||||
<p>Before</p>
|
||||
<pre>
|
||||
<prepared-statement-cache-size>32</prepared-statement-cache-size>
|
||||
</pre>
|
||||
#{pre_wrapper('<pre data-clipboard-target="pre"><prepared-statement-cache-size>32</prepared-statement-cache-size></pre>')}
|
||||
<p>After</p>
|
||||
EXPECTED
|
||||
with_settings :text_formatting => 'textile' do
|
||||
@@ -1392,14 +1390,17 @@ class ApplicationHelperTest < Redmine::HelperTest
|
||||
"/issues/1",
|
||||
:class => Issue.find(1).css_classes,
|
||||
:title => "Bug: Cannot print recipes (New)")
|
||||
expected = <<~EXPECTED
|
||||
<p>#{result1}</p>
|
||||
<p>#{result2}</p>
|
||||
<pre>
|
||||
pre = <<~PRE
|
||||
<pre data-clipboard-target="pre">
|
||||
[[CookBook documentation]]
|
||||
|
||||
#1
|
||||
</pre>
|
||||
PRE
|
||||
expected = <<~EXPECTED
|
||||
<p>#{result1}</p>
|
||||
<p>#{result2}</p>
|
||||
#{pre_wrapper(pre)}
|
||||
EXPECTED
|
||||
@project = Project.find(1)
|
||||
with_settings :text_formatting => 'textile' do
|
||||
@@ -1411,9 +1412,13 @@ class ApplicationHelperTest < Redmine::HelperTest
|
||||
raw = <<~RAW
|
||||
<pre><code>
|
||||
RAW
|
||||
pre = <<~PRE
|
||||
<pre data-clipboard-target="pre">
|
||||
<code></code>
|
||||
</pre>
|
||||
PRE
|
||||
expected = <<~EXPECTED
|
||||
<pre><code>
|
||||
</code></pre>
|
||||
#{pre_wrapper(pre)}
|
||||
EXPECTED
|
||||
@project = Project.find(1)
|
||||
with_settings :text_formatting => 'textile' do
|
||||
@@ -1435,7 +1440,7 @@ class ApplicationHelperTest < Redmine::HelperTest
|
||||
</code></pre>
|
||||
RAW
|
||||
expected = <<~EXPECTED
|
||||
<pre><code class="ECMA_script syntaxhl" data-language="ECMA_script"><span class="cm">/* Hello */</span><span class="nb">document</span><span class="p">.</span><span class="nf">write</span><span class="p">(</span><span class="dl">"</span><span class="s2">Hello World!</span><span class="dl">"</span><span class="p">);</span></code></pre>
|
||||
#{pre_wrapper('<pre data-clipboard-target="pre"><code class="ECMA_script syntaxhl" data-language="ECMA_script"><span class="cm">/* Hello */</span><span class="nb">document</span><span class="p">.</span><span class="nf">write</span><span class="p">(</span><span class="dl">"</span><span class="s2">Hello World!</span><span class="dl">"</span><span class="p">);</span></code></pre>')}
|
||||
EXPECTED
|
||||
with_settings :text_formatting => 'textile' do
|
||||
assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
|
||||
@@ -1449,7 +1454,7 @@ class ApplicationHelperTest < Redmine::HelperTest
|
||||
</code></pre>
|
||||
RAW
|
||||
expected = <<~EXPECTED
|
||||
<pre><code class="ruby syntaxhl" data-language="ruby"><span class="n">x</span> <span class="o">=</span> <span class="n">a</span> <span class="o">&</span> <span class="n">b</span></code></pre>
|
||||
#{pre_wrapper('<pre data-clipboard-target="pre"><code class="ruby syntaxhl" data-language="ruby"><span class="n">x</span> <span class="o">=</span> <span class="n">a</span> <span class="o">&</span> <span class="n">b</span></code></pre>')}
|
||||
EXPECTED
|
||||
with_settings :text_formatting => 'textile' do
|
||||
assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
|
||||
@@ -2424,4 +2429,11 @@ class ApplicationHelperTest < Redmine::HelperTest
|
||||
assert_equal({}, list_autofill_data_attributes)
|
||||
end
|
||||
end
|
||||
|
||||
def pre_wrapper(text)
|
||||
'<div class="pre-wrapper" data-controller="clipboard"><a class="copy-pre-content-link icon-only" data-action="clipboard#copyPre">' +
|
||||
'<svg class="s18 icon-svg" aria-hidden="true"><use href="/assets/icons-34cfafab.svg#icon--copy-pre-content"></use></svg></a>' +
|
||||
text +
|
||||
'</div>'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -438,13 +438,16 @@ class Redmine::WikiFormatting::MacrosTest < Redmine::HelperTest
|
||||
|
||||
{{hello_world(bar)}}
|
||||
RAW
|
||||
expected = <<~EXPECTED
|
||||
<p>Hello world! Object: NilClass, Arguments: foo and no block of text.</p>
|
||||
|
||||
<pre>
|
||||
pre = <<~PRE
|
||||
<pre data-clipboard-target="pre">
|
||||
{{hello_world(pre)}}
|
||||
!{{hello_world(pre)}}
|
||||
</pre>
|
||||
PRE
|
||||
expected = <<~EXPECTED
|
||||
<p>Hello world! Object: NilClass, Arguments: foo and no block of text.</p>
|
||||
|
||||
#{pre_wrapper(pre)}
|
||||
|
||||
<p>Hello world! Object: NilClass, Arguments: bar and no block of text.</p>
|
||||
EXPECTED
|
||||
@@ -456,7 +459,7 @@ class Redmine::WikiFormatting::MacrosTest < Redmine::HelperTest
|
||||
def test_macros_should_be_escaped_in_pre_tags
|
||||
with_settings :text_formatting => 'textile' do
|
||||
text = '<pre>{{hello_world(<tag>)}}</pre>'
|
||||
assert_equal '<pre>{{hello_world(<tag>)}}</pre>', textilizable(text)
|
||||
assert_equal pre_wrapper('<pre data-clipboard-target="pre">{{hello_world(<tag>)}}</pre>'), textilizable(text)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -623,6 +626,13 @@ class Redmine::WikiFormatting::MacrosTest < Redmine::HelperTest
|
||||
end
|
||||
end
|
||||
|
||||
def pre_wrapper(text)
|
||||
'<div class="pre-wrapper" data-controller="clipboard"><a class="copy-pre-content-link icon-only" data-action="clipboard#copyPre">' +
|
||||
'<svg class="s18 icon-svg" aria-hidden="true"><use href="/assets/icons-34cfafab.svg#icon--copy-pre-content"></use></svg></a>' +
|
||||
text +
|
||||
'</div>'
|
||||
end
|
||||
|
||||
def test_recent_pages_macro_with_project_option_should_not_disclose_private_project
|
||||
project = Project.find(5) # Private project
|
||||
|
||||
|
||||
@@ -613,7 +613,7 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase
|
||||
"class=\"ruby \"foo\" bar\"" => "data-language=\"ruby \"",
|
||||
}.each do |classattr, codeattr|
|
||||
assert_html_output({"<code #{classattr}>test</code>" => "<code #{codeattr}>test</code>"}, false)
|
||||
assert_html_output({"<pre #{classattr}>test</pre>" => "<pre>test</pre>"}, false)
|
||||
assert_html_output({"<pre #{classattr}>test</pre>" => pre_wrapper('<pre data-clipboard-target="pre">test</pre>')}, false)
|
||||
assert_html_output({"<kbd #{classattr}>test</kbd>" => "<kbd>test</kbd>"}, false)
|
||||
end
|
||||
|
||||
@@ -652,9 +652,8 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase
|
||||
end
|
||||
|
||||
def test_should_not_allow_valid_language_class_attribute_on_non_code_offtags
|
||||
%w(pre kbd).each do |tag|
|
||||
assert_html_output({"<#{tag} class=\"ruby\">test</#{tag}>" => "<#{tag}>test</#{tag}>"}, false)
|
||||
end
|
||||
assert_html_output({"<pre class=\"ruby\">test</pre>" => pre_wrapper('<pre data-clipboard-target="pre">test</pre>')}, false)
|
||||
assert_html_output({"<kbd class=\"ruby\">test</kbd>" => "<kbd>test</kbd>"}, false)
|
||||
|
||||
assert_html_output({"<notextile class=\"ruby\">test</notextile>" => "test"}, false)
|
||||
end
|
||||
@@ -755,17 +754,20 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase
|
||||
</p>
|
||||
</pre>
|
||||
STR
|
||||
expected = <<~EXPECTED
|
||||
<p>Hello world.</p>
|
||||
|
||||
<p>Foo</p>
|
||||
|
||||
<pre>
|
||||
pre = <<~PRE
|
||||
<pre data-clipboard-target="pre">
|
||||
This is a code block.
|
||||
<p>
|
||||
<!-- comments in a code block should be preserved -->
|
||||
</p>
|
||||
</pre>
|
||||
PRE
|
||||
expected = <<~EXPECTED
|
||||
<p>Hello world.</p>
|
||||
|
||||
<p>Foo</p>
|
||||
|
||||
#{pre_wrapper(pre)}
|
||||
|
||||
EXPECTED
|
||||
assert_equal expected.gsub(%r{[\r\n\t]}, ''), to_html(text).gsub(%r{[\r\n\t]}, '')
|
||||
@@ -820,4 +822,11 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase
|
||||
assert_equal expected, result.first, "section content did not match"
|
||||
assert_equal ActiveSupport::Digest.hexdigest(expected), result.last, "section hash did not match"
|
||||
end
|
||||
|
||||
def pre_wrapper(text)
|
||||
'<div class="pre-wrapper" data-controller="clipboard"><a class="copy-pre-content-link icon-only" data-action="clipboard#copyPre">' +
|
||||
'<svg class="s18 icon-svg" aria-hidden="true"><use href="/assets/icons-34cfafab.svg#icon--copy-pre-content"></use></svg></a>' +
|
||||
text +
|
||||
'</div>'
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user