Project

General

Profile

Patch #43643 » 0003-Add-stimulus-clipboard_controller-and-copypre_scrubb.patch

Takashi Kato, 2026-01-04 16:20

View differences:

app/assets/javascripts/application-legacy.js
1441 1441
$(document).on('focus', '[data-auto-complete=true]', function(event) {
1442 1442
  inlineAutoComplete(event.target);
1443 1443
});
1444
document.addEventListener("DOMContentLoaded", () => { setupCopyButtonsToPreElements(); });
app/helpers/application_helper.rb
1928 1928
  end
1929 1929

  
1930 1930
  def copy_object_url_link(url)
1931
    link_to_function(
1932
      sprite_icon('copy-link', l(:button_copy_link)), 'copyDataClipboardTextToClipboard(this);',
1933
      class: 'icon icon-copy-link',
1934
      data: {'clipboard-text' => url}
1935
    )
1931
    link_to sprite_icon('copy-link', l(:button_copy_link)),
1932
            '#',
1933
            class: 'icon icon-copy-link',
1934
            data: {clipboard_text: url, controller: 'clipboard', action: 'clipboard#copyText'}
1936 1935
  end
1937 1936

  
1938 1937
  private
app/javascript/controllers/clipboard_controller.js
1
/**
2
 * Redmine - project management software
3
 * Copyright (C) 2006-  Jean-Philippe Lang
4
 * This code is released under the GNU General Public License.
5
 */
6
import { Controller } from "@hotwired/stimulus"
7

  
8
// Connects to data-controller="clipboard"
9
export default class extends Controller {
10
  static targets = ['pre'];
11

  
12
  copyPre(e) {
13
    e.preventDefault();
14
    const element = e.currentTarget;
15
    let textToCopy = (this.preTarget.querySelector("code") || this.preTarget).textContent.replace(/\n$/, '');
16
    if (this.preTarget.querySelector("code.syntaxhl")) { textToCopy = textToCopy.replace(/ $/, ''); } // Workaround for half-width space issue in Textile's highlighted code
17

  
18
    this.copy(textToCopy).then(() => {
19
      updateSVGIcon(element, "checked");
20
      setTimeout(() => updateSVGIcon(element, "copy-pre-content"), 2000);
21
    });
22
  }
23

  
24
  copyText(e) {
25
    e.preventDefault();
26
    this.copy(e.currentTarget.dataset.clipboardText);
27

  
28
    const element = e.currentTarget.closest('.drdn.expanded');
29
    if (element !== null) {
30
      element.classList.remove('expanded');
31
    }
32
  }
33

  
34
  copy(text) {
35
    if (navigator.clipboard) {
36
      return navigator.clipboard.writeText(text).catch(() => {
37
        return this.fallback(text);
38
      });
39
    } else {
40
      return this.fallback(text);
41
    }
42
  }
43

  
44
  fallback(text) {
45
    const temp = document.createElement('textarea');
46
    temp.value = text;
47
    temp.style.position = 'fixed';
48
    temp.style.left = '-9999px';
49
    document.body.appendChild(temp);
50
    temp.select();
51
    document.execCommand('copy');
52
    document.body.removeChild(temp);
53
    return Promise.resolve();
54
  }
55
}
app/views/journals/update.js.erb
14 14
  } else {
15 15
    journal_header.append('<%= escape_javascript(render_journal_update_info(@journal)) %>');
16 16
  }
17
  setupCopyButtonsToPreElements();
18 17
  setupHoverTooltips();
19 18
<% end %>
20 19

  
lib/redmine/wiki_formatting/common_mark/formatter.rb
56 56
      SCRUBBERS = [
57 57
        SyntaxHighlightScrubber.new,
58 58
        Redmine::WikiFormatting::TablesortScrubber.new,
59
        Redmine::WikiFormatting::CopypreScrubber.new,
59 60
        FixupAutoLinksScrubber.new,
60 61
        ExternalLinksScrubber.new,
61 62
        AlertsIconsScrubber.new
lib/redmine/wiki_formatting/copypre_scrubber.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
# This code is released under the GNU General Public License.
6

  
7
module Redmine
8
  module WikiFormatting
9
    class CopypreScrubber < Loofah::Scrubber
10
      def scrub(node)
11
        return unless node.name == 'pre'
12

  
13
        node['data-clipboard-target'] = 'pre'
14
        # Wrap the <pre> element with a container and add a copy button
15
        node.wrap(wrapper)
16

  
17
        # Copy the contents of the pre tag when copyButton is clicked
18
        node.parent.prepend_child(button)
19
      end
20

  
21
      def wrapper
22
        @wrapper ||= Nokogiri::HTML5.fragment('<div class="pre-wrapper" data-controller="clipboard"></div>').children.first
23
      end
24

  
25
      def button
26
        icon = ApplicationController.helpers.sprite_icon('copy-pre-content', size: 18)
27
        button_copy = ApplicationController.helpers.l(:button_copy)
28
        html = '<a class="copy-pre-content-link icon-only" title="' + button_copy + '" data-action="clipboard#copyPre">' + icon + '</a>'
29
        @button ||= Nokogiri::HTML5.fragment(html).children.first
30
      end
31
    end
32
  end
33
end
lib/redmine/wiki_formatting/textile/formatter.rb
22 22
    module Textile
23 23
      SCRUBBERS = [
24 24
        SyntaxHighlightScrubber.new,
25
        Redmine::WikiFormatting::TablesortScrubber.new
25
        Redmine::WikiFormatting::TablesortScrubber.new,
26
        Redmine::WikiFormatting::CopypreScrubber.new
26 27
      ]
27 28

  
28 29
      class Formatter
test/helpers/application_helper_test.rb
1320 1320
      "<div class=\"bold\">content</div>" => "<p>&lt;div class=\"bold\"&gt;content&lt;/div&gt;</p>",
1321 1321
      "<script>some script;</script>" => "<p>&lt;script&gt;some script;&lt;/script&gt;</p>",
1322 1322
      # do not escape pre/code tags
1323
      "<pre>\nline 1\nline2</pre>" => "<pre>line 1\nline2</pre>",
1324
      "<pre><code>\nline 1\nline2</code></pre>" => "<pre><code>\nline 1\nline2</code></pre>",
1325
      "<pre><div class=\"foo\">content</div></pre>" => "<pre>&lt;div class=\"foo\"&gt;content&lt;/div&gt;</pre>",
1326
      "<pre><div class=\"<foo\">content</div></pre>" => "<pre>&lt;div class=\"&lt;foo\"&gt;content&lt;/div&gt;</pre>",
1323
      "<pre>\nline 1\nline2</pre>" => pre_wrapper("<pre data-clipboard-target=\"pre\">line 1\nline2</pre>"),
1324
      "<pre><code>\nline 1\nline2</code></pre>" => pre_wrapper("<pre data-clipboard-target=\"pre\"><code>\nline 1\nline2</code></pre>"),
1325
      "<pre><div class=\"foo\">content</div></pre>" => pre_wrapper("<pre data-clipboard-target=\"pre\">&lt;div class=\"foo\"&gt;content&lt;/div&gt;</pre>"),
1326
      "<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>"),
1327 1327
      "<!-- opening comment" => "<p>&lt;!-- opening comment</p>",
1328 1328
      # remove attributes including class
1329
      "<pre class='foo'>some text</pre>" => "<pre>some text</pre>",
1330
      '<pre class="foo">some text</pre>' => '<pre>some text</pre>',
1331
      "<pre class='foo bar'>some text</pre>" => "<pre>some text</pre>",
1332
      '<pre class="foo bar">some text</pre>' => '<pre>some text</pre>',
1333
      "<pre onmouseover='alert(1)'>some text</pre>" => "<pre>some text</pre>",
1329
      "<pre class='foo'>some text</pre>" => pre_wrapper('<pre data-clipboard-target="pre">some text</pre>'),
1330
      '<pre class="foo">some text</pre>' => pre_wrapper('<pre data-clipboard-target="pre">some text</pre>'),
1331
      "<pre class='foo bar'>some text</pre>" => pre_wrapper('<pre data-clipboard-target="pre">some text</pre>'),
1332
      '<pre class="foo bar">some text</pre>' => pre_wrapper('<pre data-clipboard-target="pre">some text</pre>'),
1333
      "<pre onmouseover='alert(1)'>some text</pre>" => pre_wrapper('<pre data-clipboard-target="pre">some text</pre>'),
1334 1334
      # xss
1335
      '<pre><code class=""onmouseover="alert(1)">text</code></pre>' => '<pre><code>text</code></pre>',
1336
      '<pre class=""onmouseover="alert(1)">text</pre>' => '<pre>text</pre>',
1335
      '<pre><code class=""onmouseover="alert(1)">text</code></pre>' => pre_wrapper('<pre data-clipboard-target="pre"><code>text</code></pre>'),
1336
      '<pre class=""onmouseover="alert(1)">text</pre>' => pre_wrapper('<pre data-clipboard-target="pre">text</pre>'),
1337 1337
    }
1338 1338
    with_settings :text_formatting => 'textile' do
1339 1339
      to_test.each {|text, result| assert_equal result, textilizable(text)}
......
1342 1342

  
1343 1343
  def test_allowed_html_tags
1344 1344
    to_test = {
1345
      "<pre>preformatted text</pre>" => "<pre>preformatted text</pre>",
1345
      "<pre>preformatted text</pre>" => pre_wrapper('<pre data-clipboard-target="pre">preformatted text</pre>'),
1346 1346
      "<notextile>no *textile* formatting</notextile>" => "no *textile* formatting",
1347 1347
      "<notextile>this is <tag>a tag</tag></notextile>" => "this is &lt;tag&gt;a tag&lt;/tag&gt;"
1348 1348
    }
......
1363 1363
    RAW
1364 1364
    expected = <<~EXPECTED
1365 1365
      <p>Before</p>
1366
      <pre>
1367
      &lt;prepared-statement-cache-size&gt;32&lt;/prepared-statement-cache-size&gt;
1368
      </pre>
1366
      #{pre_wrapper('<pre data-clipboard-target="pre">&lt;prepared-statement-cache-size&gt;32&lt;/prepared-statement-cache-size&gt;</pre>')}
1369 1367
      <p>After</p>
1370 1368
    EXPECTED
1371 1369
    with_settings :text_formatting => 'textile' do
......
1392 1390
                      "/issues/1",
1393 1391
                      :class => Issue.find(1).css_classes,
1394 1392
                      :title => "Bug: Cannot print recipes (New)")
1395
    expected = <<~EXPECTED
1396
      <p>#{result1}</p>
1397
      <p>#{result2}</p>
1398
      <pre>
1393
    pre = <<~PRE
1394
      <pre data-clipboard-target="pre">
1399 1395
      [[CookBook documentation]]
1400 1396

  
1401 1397
      #1
1402 1398
      </pre>
1399
    PRE
1400
    expected = <<~EXPECTED
1401
      <p>#{result1}</p>
1402
      <p>#{result2}</p>
1403
      #{pre_wrapper(pre)}
1403 1404
    EXPECTED
1404 1405
    @project = Project.find(1)
1405 1406
    with_settings :text_formatting => 'textile' do
......
1411 1412
    raw = <<~RAW
1412 1413
      <pre><code>
1413 1414
    RAW
1415
    pre = <<~PRE
1416
      <pre data-clipboard-target="pre">
1417
      <code></code>
1418
      </pre>
1419
    PRE
1414 1420
    expected = <<~EXPECTED
1415
      <pre><code>
1416
      </code></pre>
1421
      #{pre_wrapper(pre)}
1417 1422
    EXPECTED
1418 1423
    @project = Project.find(1)
1419 1424
    with_settings :text_formatting => 'textile' do
......
1435 1440
      </code></pre>
1436 1441
    RAW
1437 1442
    expected = <<~EXPECTED
1438
      <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>
1443
      #{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>')}
1439 1444
    EXPECTED
1440 1445
    with_settings :text_formatting => 'textile' do
1441 1446
      assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
......
1449 1454
      </code></pre>
1450 1455
    RAW
1451 1456
    expected = <<~EXPECTED
1452
      <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>
1457
      #{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>')}
1453 1458
    EXPECTED
1454 1459
    with_settings :text_formatting => 'textile' do
1455 1460
      assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
......
2424 2429
      assert_equal({}, list_autofill_data_attributes)
2425 2430
    end
2426 2431
  end
2432

  
2433
  def pre_wrapper(text)
2434
    '<div class="pre-wrapper" data-controller="clipboard"><a class="copy-pre-content-link icon-only" data-action="clipboard#copyPre">' +
2435
    '<svg class="s18 icon-svg" aria-hidden="true"><use href="/assets/icons-34cfafab.svg#icon--copy-pre-content"></use></svg></a>' +
2436
    text +
2437
    '</div>'
2438
  end
2427 2439
end
test/unit/lib/redmine/wiki_formatting/macros_test.rb
438 438

  
439 439
      {{hello_world(bar)}}
440 440
    RAW
441
    expected = <<~EXPECTED
442
      <p>Hello world! Object: NilClass, Arguments: foo and no block of text.</p>
443

  
444
      <pre>
441
    pre = <<~PRE
442
      <pre data-clipboard-target="pre">
445 443
      {{hello_world(pre)}}
446 444
      !{{hello_world(pre)}}
447 445
      </pre>
446
    PRE
447
    expected = <<~EXPECTED
448
      <p>Hello world! Object: NilClass, Arguments: foo and no block of text.</p>
449

  
450
      #{pre_wrapper(pre)}
448 451

  
449 452
      <p>Hello world! Object: NilClass, Arguments: bar and no block of text.</p>
450 453
    EXPECTED
......
456 459
  def test_macros_should_be_escaped_in_pre_tags
457 460
    with_settings :text_formatting => 'textile' do
458 461
      text = '<pre>{{hello_world(<tag>)}}</pre>'
459
      assert_equal '<pre>{{hello_world(&lt;tag&gt;)}}</pre>', textilizable(text)
462
      assert_equal pre_wrapper('<pre data-clipboard-target="pre">{{hello_world(&lt;tag&gt;)}}</pre>'), textilizable(text)
460 463
    end
461 464
  end
462 465

  
......
633 636
      end
634 637
    end
635 638
  end
639

  
640
  def pre_wrapper(text)
641
    '<div class="pre-wrapper" data-controller="clipboard"><a class="copy-pre-content-link icon-only" data-action="clipboard#copyPre">' +
642
    '<svg class="s18 icon-svg" aria-hidden="true"><use href="/assets/icons-34cfafab.svg#icon--copy-pre-content"></use></svg></a>' +
643
    text +
644
    '</div>'
645
  end
636 646
end
test/unit/lib/redmine/wiki_formatting/textile_formatter_test.rb
613 613
      "class=\"ruby \"foo\" bar\"" => "data-language=\"ruby \"",
614 614
    }.each do |classattr, codeattr|
615 615
      assert_html_output({"<code #{classattr}>test</code>" => "<code #{codeattr}>test</code>"}, false)
616
      assert_html_output({"<pre #{classattr}>test</pre>" => "<pre>test</pre>"}, false)
616
      assert_html_output({"<pre #{classattr}>test</pre>" => pre_wrapper('<pre data-clipboard-target="pre">test</pre>')}, false)
617 617
      assert_html_output({"<kbd #{classattr}>test</kbd>" => "<kbd>test</kbd>"}, false)
618 618
    end
619 619

  
......
652 652
  end
653 653

  
654 654
  def test_should_not_allow_valid_language_class_attribute_on_non_code_offtags
655
    %w(pre kbd).each do |tag|
656
      assert_html_output({"<#{tag} class=\"ruby\">test</#{tag}>" => "<#{tag}>test</#{tag}>"}, false)
657
    end
655
    assert_html_output({"<pre class=\"ruby\">test</pre>" => pre_wrapper('<pre data-clipboard-target="pre">test</pre>')}, false)
656
    assert_html_output({"<kbd class=\"ruby\">test</kbd>" => "<kbd>test</kbd>"}, false)
658 657

  
659 658
    assert_html_output({"<notextile class=\"ruby\">test</notextile>" => "test"}, false)
660 659
  end
......
755 754
      </p>
756 755
      </pre>
757 756
    STR
758
    expected = <<~EXPECTED
759
      <p>Hello world.</p>
760

  
761
      <p>Foo</p>
762

  
763
      <pre>
757
    pre = <<~PRE
758
      <pre data-clipboard-target="pre">
764 759
      This is a code block.
765 760
      &lt;p&gt;
766 761
      &lt;!-- comments in a code block should be preserved --&gt;
767 762
      &lt;/p&gt;
768 763
      </pre>
764
    PRE
765
    expected = <<~EXPECTED
766
      <p>Hello world.</p>
767

  
768
      <p>Foo</p>
769

  
770
      #{pre_wrapper(pre)}
769 771

  
770 772
    EXPECTED
771 773
    assert_equal expected.gsub(%r{[\r\n\t]}, ''), to_html(text).gsub(%r{[\r\n\t]}, '')
......
820 822
    assert_equal expected, result.first, "section content did not match"
821 823
    assert_equal ActiveSupport::Digest.hexdigest(expected), result.last, "section hash did not match"
822 824
  end
825

  
826
  def pre_wrapper(text)
827
    '<div class="pre-wrapper" data-controller="clipboard"><a class="copy-pre-content-link icon-only" data-action="clipboard#copyPre">' +
828
    '<svg class="s18 icon-svg" aria-hidden="true"><use href="/assets/icons-34cfafab.svg#icon--copy-pre-content"></use></svg></a>' +
829
    text +
830
    '</div>'
831
  end
823 832
end
(3-3/3)