From 7e623e1f69a96db99c3a40417fbe7ac18ae3ec86 Mon Sep 17 00:00:00 2001
From: ishikawa999 <remember929@gmail.com>
Date: Fri, 28 Feb 2025 05:44:07 +0000
Subject: [PATCH] Add copy button to pre elements

---
 app/assets/images/icons.svg            |  4 +++
 app/assets/javascripts/application.js  | 46 +++++++++++++++++++++++---
 app/assets/stylesheets/application.css | 17 ++++++++++
 app/helpers/application_helper.rb      |  8 +++++
 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 ++
 config/locales/en.yml                  |  1 +
 9 files changed, 77 insertions(+), 6 deletions(-)

diff --git a/app/assets/images/icons.svg b/app/assets/images/icons.svg
index 55925cddd..cf5f0450f 100644
--- a/app/assets/images/icons.svg
+++ b/app/assets/images/icons.svg
@@ -131,6 +131,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 c7c3e2fef..9cb082ace 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -61,6 +61,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) {
@@ -210,8 +216,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(
@@ -221,7 +226,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,6 +647,34 @@ function copyTextToClipboard(target) {
   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
+      navigator.clipboard.writeText(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());
@@ -1158,7 +1191,7 @@ function setupWikiTableSortableHeader() {
   });
 }
 
-$(function () {
+function setupHoverTooltips() {
   $("[title]:not(.no-tooltip)").tooltip({
     show: {
       delay: 400
@@ -1168,7 +1201,9 @@ $(function () {
       at: "center top"
     }
   });
-});
+}
+
+$(function() { setupHoverTooltips(); });
 
 function inlineAutoComplete(element) {
     'use strict';
@@ -1362,3 +1397,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 a17be1e81..8d7af7dab 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -1498,6 +1498,11 @@ 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;
+  padding-right: 10px;
+}
+
 div.wiki pre {
   margin: 1em 1em 1em 1.6em;
   padding: 8px;
@@ -1515,6 +1520,18 @@ div.wiki *:not(pre)>code, div.wiki>code {
   border-radius: 0.1em;
 }
 
+div.pre-wrapper a.copy-pre-content-link {
+  position: absolute;
+  top: 0px;
+  right: 8px;
+  cursor: pointer;
+  display: none;
+}
+
+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 50519c890..0f1ea1615 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -1915,6 +1915,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(
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 3432cb655..af2f850ad 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 8769a3212..ca4753dd7 100644
--- a/config/icon_source.yml
+++ b/config/icon_source.yml
@@ -209,3 +209,5 @@
   svg: square-rounded-plus
 - name: toggle-minus
   svg: square-rounded-minus
+- name: copy-pre-content
+  svg: clipboard
\ No newline at end of file
diff --git a/config/locales/en.yml b/config/locales/en.yml
index aa15095c6..3f48fe420 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -1193,6 +1193,7 @@ en:
   button_copy: Copy
   button_copy_and_follow: Copy and follow
   button_copy_link: Copy link
+  button_copied: Copied
   button_annotate: Annotate
   button_fetch_changesets: Fetch commits
   button_update: Update
-- 
2.47.1

