From 09a1d415a765c4dd2d478e3690887ac41f42a0fc Mon Sep 17 00:00:00 2001
From: ishikawa999 <remember929@gmail.com>
Date: Tue, 15 Apr 2025 04:25:48 +0000
Subject: [PATCH 1/2] Add copy button to pre elements

---
 app/assets/images/icons.svg                   |  4 +
 app/assets/javascripts/application.js         | 86 ++++++++++++++----
 app/assets/stylesheets/application.css        | 20 +++++
 app/helpers/application_helper.rb             | 10 ++-
 app/views/journals/update.js.erb              |  2 +
 app/views/layouts/base.html.erb               |  2 +
 app/views/queries/_filters.html.erb           |  1 -
 config/icon_source.yml                        |  2 +
 .../copy_pre_content_to_clipboard_test.rb     | 89 +++++++++++++++++++
 9 files changed, 196 insertions(+), 20 deletions(-)
 create mode 100644 test/system/copy_pre_content_to_clipboard_test.rb

diff --git a/app/assets/images/icons.svg b/app/assets/images/icons.svg
index df09ffd6e..55e3a042d 100644
--- a/app/assets/images/icons.svg
+++ b/app/assets/images/icons.svg
@@ -141,6 +141,10 @@
       <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"/>
       <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"/>
     </symbol>
+    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--copy-pre-content">
+      <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"/>
+      <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"/>
+    </symbol>
     <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--custom-fields">
       <path d="M20 13v-4a2 2 0 0 0 -2 -2h-12a2 2 0 0 0 -2 2v5a2 2 0 0 0 2 2h6"/>
       <path d="M15 19l2 2l4 -4"/>
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index 9054ebec0..265ac39c6 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -69,6 +69,12 @@ function updateSVGIcon(element, icon) {
   iconElement.setAttribute('href', iconPath.replace(/#.*$/g, "#icon--" + icon))
 }
 
+function createSVGIcon(icon) {
+  const clonedIcon = document.querySelector('#icon-copy-source svg').cloneNode(true);
+  updateSVGIcon(clonedIcon, icon);
+  return clonedIcon
+}
+
 function collapseAllRowGroups(el) {
   var tbody = $(el).parents('tbody').first();
   tbody.children('tr').each(function(index) {
@@ -222,8 +228,7 @@ function buildFilterRow(field, operator, values) {
   case "list_status":
   case "list_subprojects":
     const iconType = values.length > 1 ? 'toggle-minus' : 'toggle-plus';
-    const clonedIcon = document.querySelector('#icon-copy-source svg').cloneNode(true);
-    updateSVGIcon(clonedIcon, iconType);
+    const iconSvg = createSVGIcon(iconType)
 
     tr.find('.values').append(
       $('<span>', { style: 'display:none;' }).append(
@@ -233,7 +238,7 @@ function buildFilterRow(field, operator, values) {
           name: `v[${field}][]`,
         }),
         '\n',
-        $('<span>', { class: `toggle-multiselect icon-only icon-${iconType}` }).append(clonedIcon)
+        $('<span>', { class: `toggle-multiselect icon-only icon-${iconType}` }).append(iconSvg)
       )
     );
     select = tr.find('.values select');
@@ -642,23 +647,65 @@ function randomKey(size) {
   return key;
 }
 
-function copyTextToClipboard(target) {
-  if (target) {
-    var temp = document.createElement('textarea');
-    temp.value = target.getAttribute('data-clipboard-text');
-    document.body.appendChild(temp);
-    temp.select();
-    document.execCommand('copy');
-    if (temp.parentNode) {
-      temp.parentNode.removeChild(temp);
-    }
-    if ($(target).closest('.drdn.expanded').length) {
-      $(target).closest('.drdn.expanded').removeClass("expanded");
-    }
+function copyToClipboard(text) {
+  if (navigator.clipboard) {
+    return navigator.clipboard.writeText(text).catch(() => {
+      return fallbackClipboardCopy(text);
+    });
+  } else {
+    return fallbackClipboardCopy(text);
+  }
+}
+
+function fallbackClipboardCopy(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();
+}
+
+function copyDataClipboardTextToClipboard(target) {
+  copyToClipboard(target.getAttribute('data-clipboard-text'));
+
+  if ($(target).closest('.drdn.expanded').length) {
+    $(target).closest('.drdn.expanded').removeClass("expanded");
   }
   return false;
 }
 
+function setupCopyButtonsToPreElements() {
+  document.querySelectorAll('pre:not(.pre-wrapper pre)').forEach((pre) => {
+    // Wrap the <pre> element with a container and add a copy button
+    const wrapper = document.createElement("div");
+    wrapper.classList.add("pre-wrapper");
+
+    const copyButton = document.createElement("a");
+    copyButton.title = rm.I18n.buttonCopy;
+    copyButton.classList.add("copy-pre-content-link", "icon-only");
+    copyButton.append(createSVGIcon("copy-pre-content"));
+
+    wrapper.appendChild(copyButton);
+    wrapper.append(pre.cloneNode(true));
+    pre.replaceWith(wrapper);
+
+    // Copy the contents of the pre tag when copyButton is clicked
+    copyButton.addEventListener("click", (event) => {
+      event.preventDefault();
+      let textToCopy = (pre.querySelector("code") || pre).textContent.replace(/\n$/, '');
+      if (pre.querySelector("code.syntaxhl")) { textToCopy = textToCopy.replace(/ $/, ''); } // Workaround for half-width space issue in Textile's highlighted code
+      copyToClipboard(textToCopy).then(() => {
+        updateSVGIcon(copyButton, "checked");
+        setTimeout(() => updateSVGIcon(copyButton, "copy-pre-content"), 2000);
+      });
+    });
+  });
+}
+
 function updateIssueFrom(url, el) {
   $('#all_attributes input, #all_attributes textarea, #all_attributes select').each(function(){
     $(this).data('valuebeforeupdate', $(this).val());
@@ -1175,7 +1222,7 @@ function setupWikiTableSortableHeader() {
   });
 }
 
-$(function () {
+function setupHoverTooltips() {
   $("[title]:not(.no-tooltip)").tooltip({
     show: {
       delay: 400
@@ -1185,7 +1232,9 @@ $(function () {
       at: "center top"
     }
   });
-});
+}
+
+$(function() { setupHoverTooltips(); });
 
 function inlineAutoComplete(element) {
     'use strict';
@@ -1379,3 +1428,4 @@ $(document).ready(setupWikiTableSortableHeader);
 $(document).on('focus', '[data-auto-complete=true]', function(event) {
   inlineAutoComplete(event.target);
 });
+document.addEventListener("DOMContentLoaded", () => { setupCopyButtonsToPreElements(); });
diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index 85d5deb0a..1b77b95b5 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -1540,6 +1540,10 @@ div.wiki ul, div.wiki ol {margin-bottom:1em;}
 div.wiki li {line-height: 1.6; margin-bottom: 0.125rem;}
 div.wiki li>ul, div.wiki li>ol {margin-bottom: 0;}
 
+div.wiki div.pre-wrapper {
+  position: relative;
+}
+
 div.wiki pre {
   margin: 1em 1em 1em 1.6em;
   padding: 8px;
@@ -1557,6 +1561,22 @@ div.wiki *:not(pre)>code, div.wiki>code {
   border-radius: 0.1em;
 }
 
+div.pre-wrapper a.copy-pre-content-link {
+  position: absolute;
+  top: 3px;
+  right: calc(1em + 3px);
+  cursor: pointer;
+  display: none;
+  border-radius: 3px;
+  background: #fff;
+  border: 1px solid #ccc;
+  padding: 2px;
+}
+
+div.pre-wrapper:hover a.copy-pre-content-link {
+  display: block;
+}
+
 div.wiki ul.toc {
   background-color: #ffffdd;
   border: 1px solid #e4e4e4;
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index aa12831e6..2cc704be8 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -1917,6 +1917,14 @@ module ApplicationHelper
     end
   end
 
+  def heads_for_i18n
+    javascript_tag(
+      "rm = window.rm || {};" \
+      "rm.I18n = rm.I18n || {};" \
+      "rm.I18n = Object.freeze({buttonCopy: '#{l(:button_copy)}'});"
+    )
+  end
+
   def heads_for_auto_complete(project)
     data_sources = autocomplete_data_sources(project)
     javascript_tag(
@@ -1934,7 +1942,7 @@ module ApplicationHelper
 
   def copy_object_url_link(url)
     link_to_function(
-      sprite_icon('copy-link', l(:button_copy_link)), 'copyTextToClipboard(this);',
+      sprite_icon('copy-link', l(:button_copy_link)), 'copyDataClipboardTextToClipboard(this);',
       class: 'icon icon-copy-link',
       data: {'clipboard-text' => url}
     )
diff --git a/app/views/journals/update.js.erb b/app/views/journals/update.js.erb
index 227d169fc..1c7da09a1 100644
--- a/app/views/journals/update.js.erb
+++ b/app/views/journals/update.js.erb
@@ -15,6 +15,8 @@
     journal_header.append('<%= escape_javascript(render_journal_update_info(@journal)) %>');
   }
   setupWikiTableSortableHeader();
+  setupCopyButtonsToPreElements();
+  setupHoverTooltips();
 <% end %>
 
 <%= call_hook(:view_journals_update_js_bottom, { :journal => @journal }) %>
diff --git a/app/views/layouts/base.html.erb b/app/views/layouts/base.html.erb
index 9293e3dd1..cd7e2e66f 100644
--- a/app/views/layouts/base.html.erb
+++ b/app/views/layouts/base.html.erb
@@ -12,6 +12,7 @@
 <%= stylesheet_link_tag 'rtl', :media => 'all' if l(:direction) == 'rtl' %>
 <%= javascript_heads %>
 <%= heads_for_theme %>
+<%= heads_for_i18n %>
 <%= heads_for_auto_complete(@project) %>
 <%= call_hook :view_layouts_base_html_head %>
 <!-- page specific tags -->
@@ -129,6 +130,7 @@
 
 <div id="ajax-indicator" style="display:none;"><span><%= l(:label_loading) %></span></div>
 <div id="ajax-modal" style="display:none;"></div>
+<div id="icon-copy-source" style="display: none;"><%= sprite_icon('') %></div>
 
 </div>
 <%= call_hook :view_layouts_base_body_bottom %>
diff --git a/app/views/queries/_filters.html.erb b/app/views/queries/_filters.html.erb
index a1118f6ab..42756775a 100644
--- a/app/views/queries/_filters.html.erb
+++ b/app/views/queries/_filters.html.erb
@@ -22,6 +22,5 @@ $(document).ready(function(){
 <%= select_tag 'add_filter_select', filters_options_for_select(query), :name => nil %>
 </div>
 
-<div id="icon-copy-source" style="display: none;"><%= sprite_icon('') %></div>
 <%= hidden_field_tag 'f[]', '' %>
 <% include_calendar_headers_tags %>
diff --git a/config/icon_source.yml b/config/icon_source.yml
index d48944c91..dc1803cdc 100644
--- a/config/icon_source.yml
+++ b/config/icon_source.yml
@@ -220,3 +220,5 @@
   svg: eye
 - name: unwatch
   svg: eye-off
+- name: copy-pre-content
+  svg: clipboard
\ No newline at end of file
diff --git a/test/system/copy_pre_content_to_clipboard_test.rb b/test/system/copy_pre_content_to_clipboard_test.rb
new file mode 100644
index 000000000..072f9e49c
--- /dev/null
+++ b/test/system/copy_pre_content_to_clipboard_test.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+# Redmine - project management software
+# Copyright (C) 2006-  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+require_relative '../application_system_test_case'
+class CopyPreContentToClipboardSystemTest < ApplicationSystemTestCase
+  def test_copy_issue_pre_content_to_clipboard_if_common_mark
+    log_user('jsmith', 'jsmith')
+    issue = Issue.find(1)
+    issue.journals.first.update(notes: "```\ntest\n```")
+    visit "/issues/#{issue.id}"
+    # A button appears when hovering over the <pre> tag
+    find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type").hover
+    find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link")
+    # Copy pre content to Clipboard
+    find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link").click
+    # Paste the value copied to the clipboard into the textarea to get and test
+    first('.icon-edit').click
+    find('textarea#issue_notes').send_keys([modifier_key, 'v'])
+    assert_equal find('textarea#issue_notes').value, 'test'
+  end
+  def test_copy_issue_code_content_to_clipboard_if_common_mark
+    log_user('jsmith', 'jsmith')
+    issue = Issue.find(1)
+    issue.journals.first.update(notes: "```ruby\nputs \"Hello, World.\"\n```")
+    visit "/issues/#{issue.id}"
+    # A button appears when hovering over the <pre> tag
+    find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type").hover
+    find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link")
+    # Copy pre content to Clipboard
+    find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link").click
+    # Paste the value copied to the clipboard into the textarea to get and test
+    first('.icon-edit').click
+    find('textarea#issue_notes').send_keys([modifier_key, 'v'])
+    assert_equal find('textarea#issue_notes').value, 'puts "Hello, World."'
+  end
+  def test_copy_issue_pre_content_to_clipboard_if_textile
+    log_user('jsmith', 'jsmith')
+    issue = Issue.find(1)
+    issue.journals.first.update(notes: "<pre>\ntest\n</pre>")
+    with_settings text_formatting: :textile do
+      visit "/issues/#{issue.id}"
+      # A button appears when hovering over the <pre> tag
+      find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type").hover
+      find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link")
+      # Copy pre content to Clipboard
+      find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link").click
+      # Paste the value copied to the clipboard into the textarea to get and test
+      first('.icon-edit').click
+      find('textarea#issue_notes').send_keys([modifier_key, 'v'])
+      assert_equal find('textarea#issue_notes').value, 'test'
+    end
+  end
+  def test_copy_issue_code_content_to_clipboard_if_textile
+    log_user('jsmith', 'jsmith')
+    issue = Issue.find(1)
+    issue.journals.first.update(notes: "<pre><code class=\"ruby\">\nputs \"Hello, World.\"\n</code></pre>")
+    with_settings text_formatting: :textile do
+      visit "/issues/#{issue.id}"
+      # A button appears when hovering over the <pre> tag
+      find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type").hover
+      find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link")
+      # Copy pre content to Clipboard
+      find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link").click
+      # Paste the value copied to the clipboard into the textarea to get and test
+      first('.icon-edit').click
+      find('textarea#issue_notes').send_keys([modifier_key, 'v'])
+      assert_equal find('textarea#issue_notes').value, 'puts "Hello, World."'
+    end
+  end
+  private
+  def modifier_key
+    modifier = osx? ? 'command' : 'control'
+    modifier.to_sym
+  end
+end
-- 
2.49.0

