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:
Marius Balteanu
2026-01-23 16:14:04 +00:00
parent d46e990d5a
commit da5245ea06
10 changed files with 164 additions and 46 deletions

View File

@@ -1441,4 +1441,3 @@ $(document).ready(setupFilePreviewNavigation);
$(document).on('focus', '[data-auto-complete=true]', function(event) {
inlineAutoComplete(event.target);
});
document.addEventListener("DOMContentLoaded", () => { setupCopyButtonsToPreElements(); });

View File

@@ -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

View 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();
}
}

View File

@@ -14,7 +14,6 @@
} else {
journal_header.append('<%= escape_javascript(render_journal_update_info(@journal)) %>');
}
setupCopyButtonsToPreElements();
setupHoverTooltips();
<% end %>

View File

@@ -56,6 +56,7 @@ module Redmine
SCRUBBERS = [
SyntaxHighlightScrubber.new,
Redmine::WikiFormatting::TablesortScrubber.new,
Redmine::WikiFormatting::CopypreScrubber.new,
FixupAutoLinksScrubber.new,
ExternalLinksScrubber.new,
AlertsIconsScrubber.new

View 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

View File

@@ -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

View File

@@ -1320,20 +1320,20 @@ class ApplicationHelperTest < Redmine::HelperTest
"<div class=\"bold\">content</div>" => "<p>&lt;div class=\"bold\"&gt;content&lt;/div&gt;</p>",
"<script>some script;</script>" => "<p>&lt;script&gt;some script;&lt;/script&gt;</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>&lt;div class=\"foo\"&gt;content&lt;/div&gt;</pre>",
"<pre><div class=\"<foo\">content</div></pre>" => "<pre>&lt;div class=\"&lt;foo\"&gt;content&lt;/div&gt;</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\">&lt;div class=\"foo\"&gt;content&lt;/div&gt;</pre>"),
"<pre><div class=\"<foo\">content</div></pre>" => pre_wrapper("<pre data-clipboard-target=\"pre\">&lt;div class=\"&lt;foo\"&gt;content&lt;/div&gt;</pre>"),
"<!-- opening comment" => "<p>&lt;!-- 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 &lt;tag&gt;a tag&lt;/tag&gt;"
}
@@ -1363,9 +1363,7 @@ class ApplicationHelperTest < Redmine::HelperTest
RAW
expected = <<~EXPECTED
<p>Before</p>
<pre>
&lt;prepared-statement-cache-size&gt;32&lt;/prepared-statement-cache-size&gt;
</pre>
#{pre_wrapper('<pre data-clipboard-target="pre">&lt;prepared-statement-cache-size&gt;32&lt;/prepared-statement-cache-size&gt;</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">&amp;</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">&amp;</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

View File

@@ -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(&lt;tag&gt;)}}</pre>', textilizable(text)
assert_equal pre_wrapper('<pre data-clipboard-target="pre">{{hello_world(&lt;tag&gt;)}}</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

View File

@@ -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.
&lt;p&gt;
&lt;!-- comments in a code block should be preserved --&gt;
&lt;/p&gt;
</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