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 |
|