', { 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 @@
<%= l(:label_loading) %>
+<%= sprite_icon('') %>
<%= 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 %>
-<%= sprite_icon('') %>
<%= 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..32ffd4e3e
--- /dev/null
+++ b/test/system/copy_pre_content_to_clipboard_test.rb
@@ -0,0 +1,71 @@
+# 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
+ issue = Issue.find(1)
+ issue.update(description: "```\ntest\ncommon mark\n```")
+ assert_copied_pre_content_matches(issue_id: issue.id, expected_value: "test\ncommon mark")
+ end
+
+ def test_copy_issue_code_content_to_clipboard_if_common_mark
+ issue = Issue.find(1)
+ issue.update(description: "```ruby\nputs 'Hello, World.'\ncommon mark\n```")
+ assert_copied_pre_content_matches(issue_id: issue.id, expected_value: "puts 'Hello, World.'\ncommon mark")
+ end
+
+ def test_copy_issue_pre_content_to_clipboard_if_textile
+ issue = Issue.find(1)
+ issue.update(description: "\ntest\ntextile\n
")
+ with_settings text_formatting: :textile do
+ assert_copied_pre_content_matches(issue_id: issue.id, expected_value: "test\ntextile")
+ end
+ end
+
+ def test_copy_issue_code_content_to_clipboard_if_textile
+ issue = Issue.find(1)
+ issue.update(description: "\nputs 'Hello, World.'\ntextile\n
")
+ with_settings text_formatting: :textile do
+ assert_copied_pre_content_matches(issue_id: issue.id, expected_value: "puts 'Hello, World.'\ntextile")
+ end
+ end
+
+ private
+
+ def modifier_key
+ modifier = osx? ? 'command' : 'control'
+ modifier.to_sym
+ end
+
+ def assert_copied_pre_content_matches(issue_id:, expected_value:)
+ visit "/issues/#{issue_id}"
+ # A button appears when hovering over the tag
+ find("#issue_description_wiki div.pre-wrapper:first-of-type").hover
+ assert_selector('#issue_description_wiki div.pre-wrapper:first-of-type .copy-pre-content-link')
+
+ # Copy pre content to Clipboard
+ find("#issue_description_wiki 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').set('')
+ find('textarea#issue_notes').send_keys([modifier_key, 'v'])
+ assert_equal find('textarea#issue_notes').value, expected_value
+ end
+end