From 09a1d415a765c4dd2d478e3690887ac41f42a0fc Mon Sep 17 00:00:00 2001 From: ishikawa999 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 @@ + + + + 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( $('', { style: 'display:none;' }).append( @@ -233,7 +238,7 @@ function buildFilterRow(field, operator, values) { name: `v[${field}][]`, }), '\n', - $('', { class: `toggle-multiselect icon-only icon-${iconType}` }).append(clonedIcon) + $('', { 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
 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 %>
 
@@ -129,6 +130,7 @@
 
 
 
+
 
 
 <%= 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 %>
 
 
-
 <%= 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 
 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 
 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: "
\ntest\n
") + with_settings text_formatting: :textile do + visit "/issues/#{issue.id}" + # A button appears when hovering over the
 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: "
\nputs \"Hello, World.\"\n
") + with_settings text_formatting: :textile do + visit "/issues/#{issue.id}" + # A button appears when hovering over the
 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