Project

General

Profile

Feature #29214 » 0001-Add-copy-button-to-pre-elements.patch

Mizuki ISHIKAWA, 2025-04-15 08:14

View differences:

app/assets/images/icons.svg
141 141
      <path d="M13 17v-1a1 1 0 0 1 1 -1h1m3 0h1a1 1 0 0 1 1 1v1m0 3v1a1 1 0 0 1 -1 1h-1m-3 0h-1a1 1 0 0 1 -1 -1v-1"/>
142 142
      <path d="M9 3m0 2a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v0a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2z"/>
143 143
    </symbol>
144
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--copy-pre-content">
145
      <path d="M9 5h-2a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-12a2 2 0 0 0 -2 -2h-2"/>
146
      <path d="M9 3m0 2a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v0a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2z"/>
147
    </symbol>
144 148
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--custom-fields">
145 149
      <path d="M20 13v-4a2 2 0 0 0 -2 -2h-12a2 2 0 0 0 -2 2v5a2 2 0 0 0 2 2h6"/>
146 150
      <path d="M15 19l2 2l4 -4"/>
app/assets/javascripts/application.js
69 69
  iconElement.setAttribute('href', iconPath.replace(/#.*$/g, "#icon--" + icon))
70 70
}
71 71

  
72
function createSVGIcon(icon) {
73
  const clonedIcon = document.querySelector('#icon-copy-source svg').cloneNode(true);
74
  updateSVGIcon(clonedIcon, icon);
75
  return clonedIcon
76
}
77

  
72 78
function collapseAllRowGroups(el) {
73 79
  var tbody = $(el).parents('tbody').first();
74 80
  tbody.children('tr').each(function(index) {
......
222 228
  case "list_status":
223 229
  case "list_subprojects":
224 230
    const iconType = values.length > 1 ? 'toggle-minus' : 'toggle-plus';
225
    const clonedIcon = document.querySelector('#icon-copy-source svg').cloneNode(true);
226
    updateSVGIcon(clonedIcon, iconType);
231
    const iconSvg = createSVGIcon(iconType)
227 232

  
228 233
    tr.find('.values').append(
229 234
      $('<span>', { style: 'display:none;' }).append(
......
233 238
          name: `v[${field}][]`,
234 239
        }),
235 240
        '\n',
236
        $('<span>', { class: `toggle-multiselect icon-only icon-${iconType}` }).append(clonedIcon)
241
        $('<span>', { class: `toggle-multiselect icon-only icon-${iconType}` }).append(iconSvg)
237 242
      )
238 243
    );
239 244
    select = tr.find('.values select');
......
642 647
  return key;
643 648
}
644 649

  
645
function copyTextToClipboard(target) {
646
  if (target) {
647
    var temp = document.createElement('textarea');
648
    temp.value = target.getAttribute('data-clipboard-text');
649
    document.body.appendChild(temp);
650
    temp.select();
651
    document.execCommand('copy');
652
    if (temp.parentNode) {
653
      temp.parentNode.removeChild(temp);
654
    }
655
    if ($(target).closest('.drdn.expanded').length) {
656
      $(target).closest('.drdn.expanded').removeClass("expanded");
657
    }
650
function copyToClipboard(text) {
651
  if (navigator.clipboard) {
652
    return navigator.clipboard.writeText(text).catch(() => {
653
      return fallbackClipboardCopy(text);
654
    });
655
  } else {
656
    return fallbackClipboardCopy(text);
657
  }
658
}
659

  
660
function fallbackClipboardCopy(text) {
661
  const temp = document.createElement('textarea');
662
  temp.value = text;
663
  temp.style.position = 'fixed';
664
  temp.style.left = '-9999px';
665
  document.body.appendChild(temp);
666
  temp.select();
667
  document.execCommand('copy');
668
  document.body.removeChild(temp);
669
  return Promise.resolve();
670
}
671

  
672
function copyDataClipboardTextToClipboard(target) {
673
  copyToClipboard(target.getAttribute('data-clipboard-text'));
674

  
675
  if ($(target).closest('.drdn.expanded').length) {
676
    $(target).closest('.drdn.expanded').removeClass("expanded");
658 677
  }
659 678
  return false;
660 679
}
661 680

  
681
function setupCopyButtonsToPreElements() {
682
  document.querySelectorAll('pre:not(.pre-wrapper pre)').forEach((pre) => {
683
    // Wrap the <pre> element with a container and add a copy button
684
    const wrapper = document.createElement("div");
685
    wrapper.classList.add("pre-wrapper");
686

  
687
    const copyButton = document.createElement("a");
688
    copyButton.title = rm.I18n.buttonCopy;
689
    copyButton.classList.add("copy-pre-content-link", "icon-only");
690
    copyButton.append(createSVGIcon("copy-pre-content"));
691

  
692
    wrapper.appendChild(copyButton);
693
    wrapper.append(pre.cloneNode(true));
694
    pre.replaceWith(wrapper);
695

  
696
    // Copy the contents of the pre tag when copyButton is clicked
697
    copyButton.addEventListener("click", (event) => {
698
      event.preventDefault();
699
      let textToCopy = (pre.querySelector("code") || pre).textContent.replace(/\n$/, '');
700
      if (pre.querySelector("code.syntaxhl")) { textToCopy = textToCopy.replace(/ $/, ''); } // Workaround for half-width space issue in Textile's highlighted code
701
      copyToClipboard(textToCopy).then(() => {
702
        updateSVGIcon(copyButton, "checked");
703
        setTimeout(() => updateSVGIcon(copyButton, "copy-pre-content"), 2000);
704
      });
705
    });
706
  });
707
}
708

  
662 709
function updateIssueFrom(url, el) {
663 710
  $('#all_attributes input, #all_attributes textarea, #all_attributes select').each(function(){
664 711
    $(this).data('valuebeforeupdate', $(this).val());
......
1175 1222
  });
1176 1223
}
1177 1224

  
1178
$(function () {
1225
function setupHoverTooltips() {
1179 1226
  $("[title]:not(.no-tooltip)").tooltip({
1180 1227
    show: {
1181 1228
      delay: 400
......
1185 1232
      at: "center top"
1186 1233
    }
1187 1234
  });
1188
});
1235
}
1236

  
1237
$(function() { setupHoverTooltips(); });
1189 1238

  
1190 1239
function inlineAutoComplete(element) {
1191 1240
    'use strict';
......
1379 1428
$(document).on('focus', '[data-auto-complete=true]', function(event) {
1380 1429
  inlineAutoComplete(event.target);
1381 1430
});
1431
document.addEventListener("DOMContentLoaded", () => { setupCopyButtonsToPreElements(); });
app/assets/stylesheets/application.css
1540 1540
div.wiki li {line-height: 1.6; margin-bottom: 0.125rem;}
1541 1541
div.wiki li>ul, div.wiki li>ol {margin-bottom: 0;}
1542 1542

  
1543
div.wiki div.pre-wrapper {
1544
  position: relative;
1545
}
1546

  
1543 1547
div.wiki pre {
1544 1548
  margin: 1em 1em 1em 1.6em;
1545 1549
  padding: 8px;
......
1557 1561
  border-radius: 0.1em;
1558 1562
}
1559 1563

  
1564
div.pre-wrapper a.copy-pre-content-link {
1565
  position: absolute;
1566
  top: 3px;
1567
  right: calc(1em + 3px);
1568
  cursor: pointer;
1569
  display: none;
1570
  border-radius: 3px;
1571
  background: #fff;
1572
  border: 1px solid #ccc;
1573
  padding: 2px;
1574
}
1575

  
1576
div.pre-wrapper:hover a.copy-pre-content-link {
1577
  display: block;
1578
}
1579

  
1560 1580
div.wiki ul.toc {
1561 1581
  background-color: #ffffdd;
1562 1582
  border: 1px solid #e4e4e4;
app/helpers/application_helper.rb
1917 1917
    end
1918 1918
  end
1919 1919

  
1920
  def heads_for_i18n
1921
    javascript_tag(
1922
      "rm = window.rm || {};" \
1923
      "rm.I18n = rm.I18n || {};" \
1924
      "rm.I18n = Object.freeze({buttonCopy: '#{l(:button_copy)}'});"
1925
    )
1926
  end
1927

  
1920 1928
  def heads_for_auto_complete(project)
1921 1929
    data_sources = autocomplete_data_sources(project)
1922 1930
    javascript_tag(
......
1934 1942

  
1935 1943
  def copy_object_url_link(url)
1936 1944
    link_to_function(
1937
      sprite_icon('copy-link', l(:button_copy_link)), 'copyTextToClipboard(this);',
1945
      sprite_icon('copy-link', l(:button_copy_link)), 'copyDataClipboardTextToClipboard(this);',
1938 1946
      class: 'icon icon-copy-link',
1939 1947
      data: {'clipboard-text' => url}
1940 1948
    )
app/views/journals/update.js.erb
15 15
    journal_header.append('<%= escape_javascript(render_journal_update_info(@journal)) %>');
16 16
  }
17 17
  setupWikiTableSortableHeader();
18
  setupCopyButtonsToPreElements();
19
  setupHoverTooltips();
18 20
<% end %>
19 21

  
20 22
<%= call_hook(:view_journals_update_js_bottom, { :journal => @journal }) %>
app/views/layouts/base.html.erb
12 12
<%= stylesheet_link_tag 'rtl', :media => 'all' if l(:direction) == 'rtl' %>
13 13
<%= javascript_heads %>
14 14
<%= heads_for_theme %>
15
<%= heads_for_i18n %>
15 16
<%= heads_for_auto_complete(@project) %>
16 17
<%= call_hook :view_layouts_base_html_head %>
17 18
<!-- page specific tags -->
......
129 130

  
130 131
<div id="ajax-indicator" style="display:none;"><span><%= l(:label_loading) %></span></div>
131 132
<div id="ajax-modal" style="display:none;"></div>
133
<div id="icon-copy-source" style="display: none;"><%= sprite_icon('') %></div>
132 134

  
133 135
</div>
134 136
<%= call_hook :view_layouts_base_body_bottom %>
app/views/queries/_filters.html.erb
22 22
<%= select_tag 'add_filter_select', filters_options_for_select(query), :name => nil %>
23 23
</div>
24 24

  
25
<div id="icon-copy-source" style="display: none;"><%= sprite_icon('') %></div>
26 25
<%= hidden_field_tag 'f[]', '' %>
27 26
<% include_calendar_headers_tags %>
config/icon_source.yml
220 220
  svg: eye
221 221
- name: unwatch
222 222
  svg: eye-off
223
- name: copy-pre-content
224
  svg: clipboard
test/system/copy_pre_content_to_clipboard_test.rb
1
# frozen_string_literal: true
2
# Redmine - project management software
3
# Copyright (C) 2006-  Jean-Philippe Lang
4
#
5
# This program is free software; you can redistribute it and/or
6
# modify it under the terms of the GNU General Public License
7
# as published by the Free Software Foundation; either version 2
8
# of the License, or (at your option) any later version.
9
#
10
# This program is distributed in the hope that it will be useful,
11
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
# GNU General Public License for more details.
14
#
15
# You should have received a copy of the GNU General Public License
16
# along with this program; if not, write to the Free Software
17
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
18
require_relative '../application_system_test_case'
19
class CopyPreContentToClipboardSystemTest < ApplicationSystemTestCase
20
  def test_copy_issue_pre_content_to_clipboard_if_common_mark
21
    log_user('jsmith', 'jsmith')
22
    issue = Issue.find(1)
23
    issue.journals.first.update(notes: "```\ntest\n```")
24
    visit "/issues/#{issue.id}"
25
    # A button appears when hovering over the <pre> tag
26
    find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type").hover
27
    find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link")
28
    # Copy pre content to Clipboard
29
    find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link").click
30
    # Paste the value copied to the clipboard into the textarea to get and test
31
    first('.icon-edit').click
32
    find('textarea#issue_notes').send_keys([modifier_key, 'v'])
33
    assert_equal find('textarea#issue_notes').value, 'test'
34
  end
35
  def test_copy_issue_code_content_to_clipboard_if_common_mark
36
    log_user('jsmith', 'jsmith')
37
    issue = Issue.find(1)
38
    issue.journals.first.update(notes: "```ruby\nputs \"Hello, World.\"\n```")
39
    visit "/issues/#{issue.id}"
40
    # A button appears when hovering over the <pre> tag
41
    find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type").hover
42
    find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link")
43
    # Copy pre content to Clipboard
44
    find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link").click
45
    # Paste the value copied to the clipboard into the textarea to get and test
46
    first('.icon-edit').click
47
    find('textarea#issue_notes').send_keys([modifier_key, 'v'])
48
    assert_equal find('textarea#issue_notes').value, 'puts "Hello, World."'
49
  end
50
  def test_copy_issue_pre_content_to_clipboard_if_textile
51
    log_user('jsmith', 'jsmith')
52
    issue = Issue.find(1)
53
    issue.journals.first.update(notes: "<pre>\ntest\n</pre>")
54
    with_settings text_formatting: :textile do
55
      visit "/issues/#{issue.id}"
56
      # A button appears when hovering over the <pre> tag
57
      find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type").hover
58
      find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link")
59
      # Copy pre content to Clipboard
60
      find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link").click
61
      # Paste the value copied to the clipboard into the textarea to get and test
62
      first('.icon-edit').click
63
      find('textarea#issue_notes').send_keys([modifier_key, 'v'])
64
      assert_equal find('textarea#issue_notes').value, 'test'
65
    end
66
  end
67
  def test_copy_issue_code_content_to_clipboard_if_textile
68
    log_user('jsmith', 'jsmith')
69
    issue = Issue.find(1)
70
    issue.journals.first.update(notes: "<pre><code class=\"ruby\">\nputs \"Hello, World.\"\n</code></pre>")
71
    with_settings text_formatting: :textile do
72
      visit "/issues/#{issue.id}"
73
      # A button appears when hovering over the <pre> tag
74
      find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type").hover
75
      find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link")
76
      # Copy pre content to Clipboard
77
      find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link").click
78
      # Paste the value copied to the clipboard into the textarea to get and test
79
      first('.icon-edit').click
80
      find('textarea#issue_notes').send_keys([modifier_key, 'v'])
81
      assert_equal find('textarea#issue_notes').value, 'puts "Hello, World."'
82
    end
83
  end
84
  private
85
  def modifier_key
86
    modifier = osx? ? 'command' : 'control'
87
    modifier.to_sym
88
  end
89
end
(10-10/11)