summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGo MAEDA <maeda@farend.jp>2025-04-17 06:52:50 +0000
committerGo MAEDA <maeda@farend.jp>2025-04-17 06:52:50 +0000
commit38730e5b3c13950a37f79983f67d4a5a9092f09a (patch)
tree0ea7af4d8fd198754a5a0f36611e61a6f507b4c6
parent113d7f50a97314ef833debffd260e825f14a94ca (diff)
downloadredmine-38730e5b3c13950a37f79983f67d4a5a9092f09a.tar.gz
redmine-38730e5b3c13950a37f79983f67d4a5a9092f09a.zip
Add a button to copy `pre` code block content to the clipboard (#29214).
Patch by Mizuki ISHIKAWA (user:ishikawa999) and Katsuya HIDAKA (user:hidakatsuya). git-svn-id: https://svn.redmine.org/redmine/trunk@23663 e93f8b46-1217-0410-a6f0-8f06a7374b81
-rw-r--r--app/assets/images/icons.svg4
-rw-r--r--app/assets/javascripts/application.js86
-rw-r--r--app/assets/stylesheets/application.css20
-rw-r--r--app/helpers/application_helper.rb10
-rw-r--r--app/views/journals/update.js.erb2
-rw-r--r--app/views/layouts/base.html.erb2
-rw-r--r--app/views/queries/_filters.html.erb1
-rw-r--r--config/icon_source.yml2
-rw-r--r--test/system/copy_pre_content_to_clipboard_test.rb71
9 files changed, 178 insertions, 20 deletions
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..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: "<pre>\ntest\ntextile\n</pre>")
+ 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: "<pre><code class=\"ruby\">\nputs 'Hello, World.'\ntextile\n</code></pre>")
+ 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 <pre> 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