Feature #29214 » 0001-Add-copy-button-to-pre-elements.patch
app/assets/images/icons.svg | ||
---|---|---|
141 | 141 |
<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"/> |
142 | 142 |
<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"/> |
143 | 143 |
</symbol> |
144 |
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--copy-pre-content"> |
|
145 |
<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"/> |
|
146 |
<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"/> |
|
147 |
</symbol> |
|
144 | 148 |
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--custom-fields"> |
145 | 149 |
<path d="M20 13v-4a2 2 0 0 0 -2 -2h-12a2 2 0 0 0 -2 2v5a2 2 0 0 0 2 2h6"/> |
146 | 150 |
<path d="M15 19l2 2l4 -4"/> |
app/assets/javascripts/application.js | ||
---|---|---|
69 | 69 |
iconElement.setAttribute('href', iconPath.replace(/#.*$/g, "#icon--" + icon)) |
70 | 70 |
} |
71 | 71 | |
72 |
function createSVGIcon(icon) { |
|
73 |
const clonedIcon = document.querySelector('#icon-copy-source svg').cloneNode(true); |
|
74 |
updateSVGIcon(clonedIcon, icon); |
|
75 |
return clonedIcon |
|
76 |
} |
|
77 | ||
72 | 78 |
function collapseAllRowGroups(el) { |
73 | 79 |
var tbody = $(el).parents('tbody').first(); |
74 | 80 |
tbody.children('tr').each(function(index) { |
... | ... | |
222 | 228 |
case "list_status": |
223 | 229 |
case "list_subprojects": |
224 | 230 |
const iconType = values.length > 1 ? 'toggle-minus' : 'toggle-plus'; |
225 |
const clonedIcon = document.querySelector('#icon-copy-source svg').cloneNode(true); |
|
226 |
updateSVGIcon(clonedIcon, iconType); |
|
231 |
const iconSvg = createSVGIcon(iconType) |
|
227 | 232 | |
228 | 233 |
tr.find('.values').append( |
229 | 234 |
$('<span>', { style: 'display:none;' }).append( |
... | ... | |
233 | 238 |
name: `v[${field}][]`, |
234 | 239 |
}), |
235 | 240 |
'\n', |
236 |
$('<span>', { class: `toggle-multiselect icon-only icon-${iconType}` }).append(clonedIcon)
|
|
241 |
$('<span>', { class: `toggle-multiselect icon-only icon-${iconType}` }).append(iconSvg)
|
|
237 | 242 |
) |
238 | 243 |
); |
239 | 244 |
select = tr.find('.values select'); |
... | ... | |
642 | 647 |
return key; |
643 | 648 |
} |
644 | 649 | |
645 |
function copyTextToClipboard(target) { |
|
646 |
if (target) { |
|
647 |
var temp = document.createElement('textarea'); |
|
648 |
temp.value = target.getAttribute('data-clipboard-text'); |
|
649 |
document.body.appendChild(temp); |
|
650 |
temp.select(); |
|
651 |
document.execCommand('copy'); |
|
652 |
if (temp.parentNode) { |
|
653 |
temp.parentNode.removeChild(temp); |
|
654 |
} |
|
655 |
if ($(target).closest('.drdn.expanded').length) { |
|
656 |
$(target).closest('.drdn.expanded').removeClass("expanded"); |
|
657 |
} |
|
650 |
function copyToClipboard(text) { |
|
651 |
if (navigator.clipboard) { |
|
652 |
return navigator.clipboard.writeText(text).catch(() => { |
|
653 |
return fallbackClipboardCopy(text); |
|
654 |
}); |
|
655 |
} else { |
|
656 |
return fallbackClipboardCopy(text); |
|
657 |
} |
|
658 |
} |
|
659 | ||
660 |
function fallbackClipboardCopy(text) { |
|
661 |
const temp = document.createElement('textarea'); |
|
662 |
temp.value = text; |
|
663 |
temp.style.position = 'fixed'; |
|
664 |
temp.style.left = '-9999px'; |
|
665 |
document.body.appendChild(temp); |
|
666 |
temp.select(); |
|
667 |
document.execCommand('copy'); |
|
668 |
document.body.removeChild(temp); |
|
669 |
return Promise.resolve(); |
|
670 |
} |
|
671 | ||
672 |
function copyDataClipboardTextToClipboard(target) { |
|
673 |
copyToClipboard(target.getAttribute('data-clipboard-text')); |
|
674 | ||
675 |
if ($(target).closest('.drdn.expanded').length) { |
|
676 |
$(target).closest('.drdn.expanded').removeClass("expanded"); |
|
658 | 677 |
} |
659 | 678 |
return false; |
660 | 679 |
} |
661 | 680 | |
681 |
function setupCopyButtonsToPreElements() { |
|
682 |
document.querySelectorAll('pre:not(.pre-wrapper pre)').forEach((pre) => { |
|
683 |
// Wrap the <pre> element with a container and add a copy button |
|
684 |
const wrapper = document.createElement("div"); |
|
685 |
wrapper.classList.add("pre-wrapper"); |
|
686 | ||
687 |
const copyButton = document.createElement("a"); |
|
688 |
copyButton.title = rm.I18n.buttonCopy; |
|
689 |
copyButton.classList.add("copy-pre-content-link", "icon-only"); |
|
690 |
copyButton.append(createSVGIcon("copy-pre-content")); |
|
691 | ||
692 |
wrapper.appendChild(copyButton); |
|
693 |
wrapper.append(pre.cloneNode(true)); |
|
694 |
pre.replaceWith(wrapper); |
|
695 | ||
696 |
// Copy the contents of the pre tag when copyButton is clicked |
|
697 |
copyButton.addEventListener("click", (event) => { |
|
698 |
event.preventDefault(); |
|
699 |
let textToCopy = (pre.querySelector("code") || pre).textContent.replace(/\n$/, ''); |
|
700 |
if (pre.querySelector("code.syntaxhl")) { textToCopy = textToCopy.replace(/ $/, ''); } // Workaround for half-width space issue in Textile's highlighted code |
|
701 |
copyToClipboard(textToCopy).then(() => { |
|
702 |
updateSVGIcon(copyButton, "checked"); |
|
703 |
setTimeout(() => updateSVGIcon(copyButton, "copy-pre-content"), 2000); |
|
704 |
}); |
|
705 |
}); |
|
706 |
}); |
|
707 |
} |
|
708 | ||
662 | 709 |
function updateIssueFrom(url, el) { |
663 | 710 |
$('#all_attributes input, #all_attributes textarea, #all_attributes select').each(function(){ |
664 | 711 |
$(this).data('valuebeforeupdate', $(this).val()); |
... | ... | |
1175 | 1222 |
}); |
1176 | 1223 |
} |
1177 | 1224 | |
1178 |
$(function () {
|
|
1225 |
function setupHoverTooltips() {
|
|
1179 | 1226 |
$("[title]:not(.no-tooltip)").tooltip({ |
1180 | 1227 |
show: { |
1181 | 1228 |
delay: 400 |
... | ... | |
1185 | 1232 |
at: "center top" |
1186 | 1233 |
} |
1187 | 1234 |
}); |
1188 |
}); |
|
1235 |
} |
|
1236 | ||
1237 |
$(function() { setupHoverTooltips(); }); |
|
1189 | 1238 | |
1190 | 1239 |
function inlineAutoComplete(element) { |
1191 | 1240 |
'use strict'; |
... | ... | |
1379 | 1428 |
$(document).on('focus', '[data-auto-complete=true]', function(event) { |
1380 | 1429 |
inlineAutoComplete(event.target); |
1381 | 1430 |
}); |
1431 |
document.addEventListener("DOMContentLoaded", () => { setupCopyButtonsToPreElements(); }); |
app/assets/stylesheets/application.css | ||
---|---|---|
1540 | 1540 |
div.wiki li {line-height: 1.6; margin-bottom: 0.125rem;} |
1541 | 1541 |
div.wiki li>ul, div.wiki li>ol {margin-bottom: 0;} |
1542 | 1542 | |
1543 |
div.wiki div.pre-wrapper { |
|
1544 |
position: relative; |
|
1545 |
} |
|
1546 | ||
1543 | 1547 |
div.wiki pre { |
1544 | 1548 |
margin: 1em 1em 1em 1.6em; |
1545 | 1549 |
padding: 8px; |
... | ... | |
1557 | 1561 |
border-radius: 0.1em; |
1558 | 1562 |
} |
1559 | 1563 | |
1564 |
div.pre-wrapper a.copy-pre-content-link { |
|
1565 |
position: absolute; |
|
1566 |
top: 3px; |
|
1567 |
right: calc(1em + 3px); |
|
1568 |
cursor: pointer; |
|
1569 |
display: none; |
|
1570 |
border-radius: 3px; |
|
1571 |
background: #fff; |
|
1572 |
border: 1px solid #ccc; |
|
1573 |
padding: 2px; |
|
1574 |
} |
|
1575 | ||
1576 |
div.pre-wrapper:hover a.copy-pre-content-link { |
|
1577 |
display: block; |
|
1578 |
} |
|
1579 | ||
1560 | 1580 |
div.wiki ul.toc { |
1561 | 1581 |
background-color: #ffffdd; |
1562 | 1582 |
border: 1px solid #e4e4e4; |
app/helpers/application_helper.rb | ||
---|---|---|
1917 | 1917 |
end |
1918 | 1918 |
end |
1919 | 1919 | |
1920 |
def heads_for_i18n |
|
1921 |
javascript_tag( |
|
1922 |
"rm = window.rm || {};" \ |
|
1923 |
"rm.I18n = rm.I18n || {};" \ |
|
1924 |
"rm.I18n = Object.freeze({buttonCopy: '#{l(:button_copy)}'});" |
|
1925 |
) |
|
1926 |
end |
|
1927 | ||
1920 | 1928 |
def heads_for_auto_complete(project) |
1921 | 1929 |
data_sources = autocomplete_data_sources(project) |
1922 | 1930 |
javascript_tag( |
... | ... | |
1934 | 1942 | |
1935 | 1943 |
def copy_object_url_link(url) |
1936 | 1944 |
link_to_function( |
1937 |
sprite_icon('copy-link', l(:button_copy_link)), 'copyTextToClipboard(this);', |
|
1945 |
sprite_icon('copy-link', l(:button_copy_link)), 'copyDataClipboardTextToClipboard(this);',
|
|
1938 | 1946 |
class: 'icon icon-copy-link', |
1939 | 1947 |
data: {'clipboard-text' => url} |
1940 | 1948 |
) |
app/views/journals/update.js.erb | ||
---|---|---|
15 | 15 |
journal_header.append('<%= escape_javascript(render_journal_update_info(@journal)) %>'); |
16 | 16 |
} |
17 | 17 |
setupWikiTableSortableHeader(); |
18 |
setupCopyButtonsToPreElements(); |
|
19 |
setupHoverTooltips(); |
|
18 | 20 |
<% end %> |
19 | 21 | |
20 | 22 |
<%= call_hook(:view_journals_update_js_bottom, { :journal => @journal }) %> |
app/views/layouts/base.html.erb | ||
---|---|---|
12 | 12 |
<%= stylesheet_link_tag 'rtl', :media => 'all' if l(:direction) == 'rtl' %> |
13 | 13 |
<%= javascript_heads %> |
14 | 14 |
<%= heads_for_theme %> |
15 |
<%= heads_for_i18n %> |
|
15 | 16 |
<%= heads_for_auto_complete(@project) %> |
16 | 17 |
<%= call_hook :view_layouts_base_html_head %> |
17 | 18 |
<!-- page specific tags --> |
... | ... | |
129 | 130 | |
130 | 131 |
<div id="ajax-indicator" style="display:none;"><span><%= l(:label_loading) %></span></div> |
131 | 132 |
<div id="ajax-modal" style="display:none;"></div> |
133 |
<div id="icon-copy-source" style="display: none;"><%= sprite_icon('') %></div> |
|
132 | 134 | |
133 | 135 |
</div> |
134 | 136 |
<%= call_hook :view_layouts_base_body_bottom %> |
app/views/queries/_filters.html.erb | ||
---|---|---|
22 | 22 |
<%= select_tag 'add_filter_select', filters_options_for_select(query), :name => nil %> |
23 | 23 |
</div> |
24 | 24 | |
25 |
<div id="icon-copy-source" style="display: none;"><%= sprite_icon('') %></div> |
|
26 | 25 |
<%= hidden_field_tag 'f[]', '' %> |
27 | 26 |
<% include_calendar_headers_tags %> |
config/icon_source.yml | ||
---|---|---|
220 | 220 |
svg: eye |
221 | 221 |
- name: unwatch |
222 | 222 |
svg: eye-off |
223 |
- name: copy-pre-content |
|
224 |
svg: clipboard |
test/system/copy_pre_content_to_clipboard_test.rb | ||
---|---|---|
1 |
# frozen_string_literal: true |
|
2 |
# Redmine - project management software |
|
3 |
# Copyright (C) 2006- Jean-Philippe Lang |
|
4 |
# |
|
5 |
# This program is free software; you can redistribute it and/or |
|
6 |
# modify it under the terms of the GNU General Public License |
|
7 |
# as published by the Free Software Foundation; either version 2 |
|
8 |
# of the License, or (at your option) any later version. |
|
9 |
# |
|
10 |
# This program is distributed in the hope that it will be useful, |
|
11 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
12 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
13 |
# GNU General Public License for more details. |
|
14 |
# |
|
15 |
# You should have received a copy of the GNU General Public License |
|
16 |
# along with this program; if not, write to the Free Software |
|
17 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
18 |
require_relative '../application_system_test_case' |
|
19 |
class CopyPreContentToClipboardSystemTest < ApplicationSystemTestCase |
|
20 |
def test_copy_issue_pre_content_to_clipboard_if_common_mark |
|
21 |
log_user('jsmith', 'jsmith') |
|
22 |
issue = Issue.find(1) |
|
23 |
issue.journals.first.update(notes: "```\ntest\n```") |
|
24 |
visit "/issues/#{issue.id}" |
|
25 |
# A button appears when hovering over the <pre> tag |
|
26 |
find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type").hover |
|
27 |
find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link") |
|
28 |
# Copy pre content to Clipboard |
|
29 |
find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link").click |
|
30 |
# Paste the value copied to the clipboard into the textarea to get and test |
|
31 |
first('.icon-edit').click |
|
32 |
find('textarea#issue_notes').send_keys([modifier_key, 'v']) |
|
33 |
assert_equal find('textarea#issue_notes').value, 'test' |
|
34 |
end |
|
35 |
def test_copy_issue_code_content_to_clipboard_if_common_mark |
|
36 |
log_user('jsmith', 'jsmith') |
|
37 |
issue = Issue.find(1) |
|
38 |
issue.journals.first.update(notes: "```ruby\nputs \"Hello, World.\"\n```") |
|
39 |
visit "/issues/#{issue.id}" |
|
40 |
# A button appears when hovering over the <pre> tag |
|
41 |
find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type").hover |
|
42 |
find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link") |
|
43 |
# Copy pre content to Clipboard |
|
44 |
find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link").click |
|
45 |
# Paste the value copied to the clipboard into the textarea to get and test |
|
46 |
first('.icon-edit').click |
|
47 |
find('textarea#issue_notes').send_keys([modifier_key, 'v']) |
|
48 |
assert_equal find('textarea#issue_notes').value, 'puts "Hello, World."' |
|
49 |
end |
|
50 |
def test_copy_issue_pre_content_to_clipboard_if_textile |
|
51 |
log_user('jsmith', 'jsmith') |
|
52 |
issue = Issue.find(1) |
|
53 |
issue.journals.first.update(notes: "<pre>\ntest\n</pre>") |
|
54 |
with_settings text_formatting: :textile do |
|
55 |
visit "/issues/#{issue.id}" |
|
56 |
# A button appears when hovering over the <pre> tag |
|
57 |
find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type").hover |
|
58 |
find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link") |
|
59 |
# Copy pre content to Clipboard |
|
60 |
find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link").click |
|
61 |
# Paste the value copied to the clipboard into the textarea to get and test |
|
62 |
first('.icon-edit').click |
|
63 |
find('textarea#issue_notes').send_keys([modifier_key, 'v']) |
|
64 |
assert_equal find('textarea#issue_notes').value, 'test' |
|
65 |
end |
|
66 |
end |
|
67 |
def test_copy_issue_code_content_to_clipboard_if_textile |
|
68 |
log_user('jsmith', 'jsmith') |
|
69 |
issue = Issue.find(1) |
|
70 |
issue.journals.first.update(notes: "<pre><code class=\"ruby\">\nputs \"Hello, World.\"\n</code></pre>") |
|
71 |
with_settings text_formatting: :textile do |
|
72 |
visit "/issues/#{issue.id}" |
|
73 |
# A button appears when hovering over the <pre> tag |
|
74 |
find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type").hover |
|
75 |
find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link") |
|
76 |
# Copy pre content to Clipboard |
|
77 |
find("#journal-#{issue.journals.first.id}-notes div.pre-wrapper:first-of-type .copy-pre-content-link").click |
|
78 |
# Paste the value copied to the clipboard into the textarea to get and test |
|
79 |
first('.icon-edit').click |
|
80 |
find('textarea#issue_notes').send_keys([modifier_key, 'v']) |
|
81 |
assert_equal find('textarea#issue_notes').value, 'puts "Hello, World."' |
|
82 |
end |
|
83 |
end |
|
84 |
private |
|
85 |
def modifier_key |
|
86 |
modifier = osx? ? 'command' : 'control' |
|
87 |
modifier.to_sym |
|
88 |
end |
|
89 |
end |