Commit 20a423dd authored by Phil Hughes's avatar Phil Hughes

Adds copy button to every markdown code block

Creates a custom element, `copy-code` that gets added to
every code block in markdown.
This element is a button that has the correct data attributes to copy
the code it is linked to.
Adds a `MutationObserver` to add this copy button to older cached
markdown comments.

Closes https://gitlab.com/gitlab-org/gitlab/-/issues/21172
parent 20d55baf
......@@ -76,7 +76,7 @@ export default {
},
},
safeHtmlConfig: {
ADD_TAGS: ['use', 'gl-emoji'],
ADD_TAGS: ['use', 'gl-emoji', 'copy-code'],
},
};
</script>
......
import { uniqueId } from 'lodash';
import { __ } from '~/locale';
import { spriteIcon } from '~/lib/utils/common_utils';
import { setAttributes } from '~/lib/utils/dom_utils';
class CopyCodeButton extends HTMLElement {
connectedCallback() {
this.for = uniqueId('code-');
this.parentNode.querySelector('pre').setAttribute('id', this.for);
this.appendChild(this.createButton());
}
createButton() {
const button = document.createElement('button');
setAttributes(button, {
type: 'button',
class: 'btn btn-default btn-md gl-button btn-icon has-tooltip',
'data-title': __('Copy to clipboard'),
'data-clipboard-target': `pre#${this.for}`,
});
button.innerHTML = spriteIcon('copy-to-clipboard');
return button;
}
}
function addCodeButton() {
[...document.querySelectorAll('pre.code.js-syntax-highlight')]
.filter((el) => !el.closest('.js-markdown-code'))
.forEach((el) => {
const copyCodeEl = document.createElement('copy-code');
copyCodeEl.setAttribute('for', uniqueId('code-'));
const wrapper = document.createElement('div');
wrapper.className = 'gl-relative markdown-code-block js-markdown-code';
wrapper.appendChild(el.cloneNode(true));
wrapper.appendChild(copyCodeEl);
el.parentNode.insertBefore(wrapper, el);
el.remove();
});
}
export const initCopyCodeButton = (selector = '#content-body') => {
if (!customElements.get('copy-code')) {
customElements.define('copy-code', CopyCodeButton);
}
const el = document.querySelector(selector);
if (!el) return () => {};
const observer = new MutationObserver(() => addCodeButton());
observer.observe(document.querySelector(selector), {
childList: true,
subtree: true,
});
return () => observer.disconnect();
};
......@@ -17,7 +17,7 @@ export default CodeBlockLowlight.extend({
};
},
renderHTML({ HTMLAttributes }) {
return ['pre', HTMLAttributes, ['code', {}, 0]];
return ['div', ['pre', HTMLAttributes, ['code', {}, 0]]];
},
}).configure({
lowlight,
......
import { Node } from '@tiptap/core';
import { PARSE_HTML_PRIORITY_LOWEST } from '../constants';
const getDiv = (element) => {
if (element.nodeName === 'DIV') return element;
return element.querySelector('div');
};
export default Node.create({
name: 'division',
content: 'block*',
group: 'block',
defining: true,
addAttributes() {
return {
className: {
default: null,
parseHTML: (element) => getDiv(element).className || null,
},
};
},
parseHTML() {
return [{ tag: 'div', priority: PARSE_HTML_PRIORITY_LOWEST }];
},
......
......@@ -138,7 +138,16 @@ const defaultSerializerConfig = {
state.write('```');
state.closeBlock(node);
},
[Division.name]: renderHTMLNode('div'),
[Division.name]: (state, node) => {
if (node.attrs.className?.includes('js-markdown-code')) {
state.renderInline(node);
} else {
const newNode = node;
delete newNode.attrs.className;
renderHTMLNode('div')(state, newNode);
}
},
[DescriptionList.name]: renderHTMLNode('dl', true),
[DescriptionItem.name]: (state, node, parent, index) => {
if (index === 1) state.ensureNewLine();
......
......@@ -133,7 +133,7 @@ export default {
}
},
},
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'copy-code'] },
};
</script>
......
......@@ -3,7 +3,7 @@ import { getNormalizedURL, getBaseURL, relativePathToAbsolute } from '~/lib/util
const defaultConfig = {
// Safely allow SVG <use> tags
ADD_TAGS: ['use', 'gl-emoji'],
ADD_TAGS: ['use', 'gl-emoji', 'copy-code'],
// Prevent possible XSS attacks with data-* attributes used by @rails/ujs
// See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1421
FORBID_ATTR: ['data-remote', 'data-url', 'data-type', 'data-method'],
......
......@@ -89,3 +89,17 @@ export const getParents = (element) => {
return parents;
};
/**
* This method takes a HTML element and an object of attributes
* to save repeated calls to `setAttribute` when multiple
* attributes need to be set.
*
* @param {HTMLElement} el
* @param {Object} attributes
*/
export const setAttributes = (el, attributes) => {
Object.keys(attributes).forEach((key) => {
el.setAttribute(key, attributes[key]);
});
};
......@@ -36,6 +36,7 @@ import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
import initBroadcastNotifications from './broadcast_notification';
import { initTopNav } from './nav';
import { initCopyCodeButton } from './behaviors/copy_code';
import 'ee_else_ce/main_ee';
import 'jh_else_ce/main_jh';
......@@ -97,6 +98,7 @@ function deferredInitialisation() {
initPersistentUserCallouts();
initDefaultTrackers();
initFeatureHighlight();
initCopyCodeButton();
if (gon.features?.newHeaderSearch) {
initHeaderSearchApp();
......
......@@ -149,7 +149,7 @@ export default {
},
},
safeHtmlConfig: {
ADD_TAGS: ['use', 'gl-emoji'],
ADD_TAGS: ['use', 'gl-emoji', 'copy-code'],
},
};
</script>
......
......@@ -451,3 +451,16 @@ fieldset[disabled] .btn,
box-shadow: none;
border-width: 1px;
}
copy-code {
@include gl-absolute;
@include gl-transition-medium;
@include gl-opacity-0;
top: 7px;
right: $input-horizontal-padding;
.markdown-code-block:hover & {
@include gl-opacity-10;
}
}
......@@ -625,6 +625,7 @@ body {
/** CODE **/
pre {
@include gl-relative;
font-family: $monospace-font;
display: block;
padding: $gl-padding-8 $input-horizontal-padding;
......@@ -636,6 +637,11 @@ pre {
background-color: $gray-light;
border: 1px solid $gray-100;
border-radius: $border-radius-small;
// Select only code elements that will have the copy code button
.markdown-code-block & {
padding: $input-horizontal-padding;
}
}
code {
......
......@@ -58,10 +58,10 @@ module Banzai
sourcepos_attr = sourcepos ? "data-sourcepos=\"#{sourcepos}\"" : ''
highlighted = %(<pre #{sourcepos_attr} class="#{css_classes}"
highlighted = %(<div class="gl-relative markdown-code-block js-markdown-code"><pre #{sourcepos_attr} class="#{css_classes}"
lang="#{language}"
#{lang_params}
v-pre="true"><code>#{code}</code></pre>)
v-pre="true"><code>#{code}</code></pre><copy-code></copy-code></div>)
# Extracted to a method to measure it
replace_parent_pre_element(node, highlighted)
......
......@@ -111,7 +111,6 @@
# `html` value based on the spec failure that is printed out.
---
#- name: an_example_of_pending
# pending: 'This is an example of the pending attribute: http://example.com'
# markdown: ;)
......@@ -297,7 +296,10 @@
console.log('hello world')
```
html: |-
<div class="gl-relative markdown-code-block js-markdown-code">
<pre data-sourcepos="1:1-3:3" class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true"><code><span id="LC1" class="line" lang="javascript"> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">hello world</span><span class="dl">'</span><span class="p">)</span></span></code></pre>
<copy-code></copy-code>
</div>
- name: color_chips
markdown: |-
......@@ -469,23 +471,34 @@
}
;;;
html: |-
<div class="gl-relative markdown-code-block js-markdown-code">
<pre data-sourcepos="1:1-5:3" class="code highlight js-syntax-highlight language-json" lang="json" data-lang-params="frontmatter" v-pre="true"><code><span id="LC1" class="line" lang="json"><span class="p">{</span></span>
<span id="LC2" class="line" lang="json"><span class="w"> </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Page title"</span></span>
<span id="LC3" class="line" lang="json"><span class="p">}</span></span></code></pre>
<copy-code></copy-code>
</div>
- name: frontmatter_toml
markdown: |-
+++
title = "Page title"
+++
html: <pre data-sourcepos="1:1-3:3" class="code highlight js-syntax-highlight language-toml" lang="toml" data-lang-params="frontmatter" v-pre="true"><code><span id="LC1" class="line" lang="toml"><span class="py">title</span> <span class="p">=</span> <span class="s">"Page title"</span></span></code></pre>
html: |-
<div class="gl-relative markdown-code-block js-markdown-code">
<pre data-sourcepos="1:1-3:3" class="code highlight js-syntax-highlight language-toml" lang="toml" data-lang-params="frontmatter" v-pre="true"><code><span id="LC1" class="line" lang="toml"><span class="py">title</span> <span class="p">=</span> <span class="s">"Page title"</span></span></code></pre>
<copy-code></copy-code>
</div>
- name: frontmatter_yaml
markdown: |-
---
title: Page title
---
html: <pre data-sourcepos="1:1-3:3" class="code highlight js-syntax-highlight language-yaml" lang="yaml" data-lang-params="frontmatter" v-pre="true"><code><span id="LC1" class="line" lang="yaml"><span class="na">title</span><span class="pi">:</span> <span class="s">Page title</span></span></code></pre>
html: |-
<div class="gl-relative markdown-code-block js-markdown-code">
<pre data-sourcepos="1:1-3:3" class="code highlight js-syntax-highlight language-yaml" lang="yaml" data-lang-params="frontmatter" v-pre="true"><code><span id="LC1" class="line" lang="yaml"><span class="na">title</span><span class="pi">:</span> <span class="s">Page title</span></span></code></pre>
<copy-code></copy-code>
</div>
- name: hard_break
markdown: |-
......@@ -617,7 +630,10 @@
html: |-
<p data-sourcepos="1:1-1:36" dir="auto">This math is inline <code class="code math js-render-math" data-math-style="inline">a^2+b^2=c^2</code>.</p>
<p data-sourcepos="3:1-3:27" dir="auto">This is on a separate line:</p>
<div class="gl-relative markdown-code-block js-markdown-code">
<pre data-sourcepos="5:1-7:3" class="code highlight js-syntax-highlight language-math js-render-math" lang="math" v-pre="true" data-math-style="display"><code><span id="LC1" class="line" lang="math">a^2+b^2=c^2</span></code></pre>
<copy-code></copy-code>
</div>
- name: ordered_list
markdown: |-
......
......@@ -6,6 +6,7 @@ import {
isElementVisible,
isElementHidden,
getParents,
setAttributes,
} from '~/lib/utils/dom_utils';
const TEST_MARGIN = 5;
......@@ -208,4 +209,15 @@ describe('DOM Utils', () => {
]);
});
});
describe('setAttributes', () => {
it('sets multiple attribues on element', () => {
const div = document.createElement('div');
setAttributes(div, { class: 'test', title: 'another test' });
expect(div.getAttribute('class')).toBe('test');
expect(div.getAttribute('title')).toBe('another test');
});
});
});
......@@ -584,9 +584,9 @@ FooBar
it 'preserves code color scheme' do
object = create_object("```ruby\ndef test\n 'hello world'\nend\n```")
expected = "<pre class=\"code highlight js-syntax-highlight language-ruby\">" \
expected = "\n<pre class=\"code highlight js-syntax-highlight language-ruby\">" \
"<code><span class=\"line\"><span class=\"k\">def</span> <span class=\"nf\">test</span>...</span>\n" \
"</code></pre>"
"</code></pre>\n"
expect(first_line_in_markdown(object, attribute, 150, project: project)).to eq(expected)
end
......
......@@ -24,7 +24,7 @@ RSpec.describe Banzai::Filter::SyntaxHighlightFilter do
it "highlights as plaintext" do
result = filter('<pre><code>def fun end</code></pre>')
expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">def fun end</span></code></pre>')
expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">def fun end</span></code></pre><copy-code></copy-code></div>')
end
include_examples "XSS prevention", ""
......@@ -46,7 +46,7 @@ RSpec.describe Banzai::Filter::SyntaxHighlightFilter do
filter('<pre><code lang="ruby">def fun end</code></pre>')
end
expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight language-ruby" lang="ruby" v-pre="true"><code><span id="LC1" class="line" lang="ruby"><span class="k">def</span> <span class="nf">fun</span> <span class="k">end</span></span></code></pre>')
expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-ruby" lang="ruby" v-pre="true"><code><span id="LC1" class="line" lang="ruby"><span class="k">def</span> <span class="nf">fun</span> <span class="k">end</span></span></code></pre><copy-code></copy-code></div>')
end
include_examples "XSS prevention", "ruby"
......@@ -60,7 +60,7 @@ RSpec.describe Banzai::Filter::SyntaxHighlightFilter do
filter('<pre><code lang="gnuplot">This is a test</code></pre>')
end
expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">This is a test</span></code></pre>')
expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">This is a test</span></code></pre><copy-code></copy-code></div>')
end
include_examples "XSS prevention", "gnuplot"
......@@ -79,7 +79,7 @@ RSpec.describe Banzai::Filter::SyntaxHighlightFilter do
filter(%{<pre><code lang="#{lang}">This is a test</code></pre>})
end
expect(result.to_html).to eq(%{<pre class="code highlight js-syntax-highlight language-#{lang}" lang="#{lang}" v-pre="true"><code><span id="LC1" class="line" lang="#{lang}">This is a test</span></code></pre>})
expect(result.to_html.delete("\n")).to eq(%{<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-#{lang}" lang="#{lang}" v-pre="true"><code><span id="LC1" class="line" lang="#{lang}">This is a test</span></code></pre><copy-code></copy-code></div>})
end
include_examples "XSS prevention", lang
......@@ -103,7 +103,7 @@ RSpec.describe Banzai::Filter::SyntaxHighlightFilter do
filter(%{<pre><code lang="#{lang}#{delimiter}#{lang_params}">This is a test</code></pre>})
end
expect(result.to_html).to eq(%{<pre class="code highlight js-syntax-highlight language-#{lang}" lang="#{lang}" #{data_attr}="#{lang_params}" v-pre="true"><code><span id="LC1" class="line" lang="#{lang}">This is a test</span></code></pre>})
expect(result.to_html.delete("\n")).to eq(%{<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-#{lang}" lang="#{lang}" #{data_attr}="#{lang_params}" v-pre="true"><code><span id="LC1" class="line" lang="#{lang}">This is a test</span></code></pre><copy-code></copy-code></div>})
end
include_examples "XSS prevention", lang
......@@ -126,7 +126,7 @@ RSpec.describe Banzai::Filter::SyntaxHighlightFilter do
let(:lang_params) { '-1+10' }
let(:expected_result) do
%{<pre class="code highlight js-syntax-highlight language-#{lang}" lang="#{lang}" #{data_attr}="#{lang_params} more-things" v-pre="true"><code><span id="LC1" class="line" lang="#{lang}">This is a test</span></code></pre>}
%{<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-#{lang}" lang="#{lang}" #{data_attr}="#{lang_params} more-things" v-pre="true"><code><span id="LC1" class="line" lang="#{lang}">This is a test</span></code></pre><copy-code></copy-code></div>}
end
context 'when delimiter is space' do
......@@ -134,11 +134,11 @@ RSpec.describe Banzai::Filter::SyntaxHighlightFilter do
if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml)
result = filter(%{<pre lang="#{lang}" data-meta="#{lang_params} more-things"><code>This is a test</code></pre>})
expect(result.to_html).to eq(expected_result)
expect(result.to_html.delete("\n")).to eq(expected_result)
else
result = filter(%{<pre><code lang="#{lang}#{delimiter}#{lang_params}#{delimiter}more-things">This is a test</code></pre>})
expect(result.to_html).to eq(%{<pre class="code highlight js-syntax-highlight language-#{lang}" lang="#{lang}" #{data_attr}="#{lang_params}#{delimiter}more-things" v-pre="true"><code><span id="LC1" class="line" lang="#{lang}">This is a test</span></code></pre>})
expect(result.to_html.delete("\n")).to eq(%{<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-#{lang}" lang="#{lang}" #{data_attr}="#{lang_params}#{delimiter}more-things" v-pre="true"><code><span id="LC1" class="line" lang="#{lang}">This is a test</span></code></pre><copy-code></copy-code></div>})
end
end
end
......@@ -148,9 +148,9 @@ RSpec.describe Banzai::Filter::SyntaxHighlightFilter do
result = filter(%{<pre lang="#{lang}#{delimiter}#{lang_params} more-things"><code>This is a test</code></pre>})
if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml)
expect(result.to_html).to eq(expected_result)
expect(result.to_html.delete("\n")).to eq(expected_result)
else
expect(result.to_html).to eq(%{<pre class=\"code highlight js-syntax-highlight language-plaintext\" lang=\"plaintext\" v-pre=\"true\"><code><span id=\"LC1\" class=\"line\" lang=\"plaintext\">This is a test</span></code></pre>})
expect(result.to_html.delete("\n")).to eq(%{<div class="gl-relative markdown-code-block js-markdown-code"><pre class=\"code highlight js-syntax-highlight language-plaintext\" lang=\"plaintext\" v-pre=\"true\"><code><span id=\"LC1\" class=\"line\" lang=\"plaintext\">This is a test</span></code></pre><copy-code></copy-code></div>})
end
end
end
......@@ -161,7 +161,7 @@ RSpec.describe Banzai::Filter::SyntaxHighlightFilter do
it "includes it in the highlighted code block" do
result = filter('<pre data-sourcepos="1:1-3:3"><code lang="plaintext">This is a test</code></pre>')
expect(result.to_html).to eq('<pre data-sourcepos="1:1-3:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">This is a test</span></code></pre>')
expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre data-sourcepos="1:1-3:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">This is a test</span></code></pre><copy-code></copy-code></div>')
end
end
......@@ -179,7 +179,7 @@ RSpec.describe Banzai::Filter::SyntaxHighlightFilter do
filter('<pre><code lang="ruby">This is a test</code></pre>')
end
expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight" lang="" v-pre="true"><code><span id="LC1" class="line" lang="">This is a test</span></code></pre>')
expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight" lang="" v-pre="true"><code><span id="LC1" class="line" lang="">This is a test</span></code></pre><copy-code></copy-code></div>')
end
include_examples "XSS prevention", "ruby"
......
......@@ -97,9 +97,9 @@ module Gitlab
input = '```mypre"><script>alert(3)</script>'
output =
if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml)
"<div>\n<div>\n<pre class=\"code highlight js-syntax-highlight language-plaintext\" lang=\"plaintext\" v-pre=\"true\"><code></code></pre>\n</div>\n</div>"
"<div>\n<div>\n<div class=\"gl-relative markdown-code-block js-markdown-code\">\n<pre class=\"code highlight js-syntax-highlight language-plaintext\" lang=\"plaintext\" v-pre=\"true\"><code></code></pre>\n<copy-code></copy-code>\n</div>\n</div>\n</div>"
else
"<div>\n<div>\n<pre class=\"code highlight js-syntax-highlight language-plaintext\" lang=\"plaintext\" v-pre=\"true\"><code><span id=\"LC1\" class=\"line\" lang=\"plaintext\">\"&gt;</span></code></pre>\n</div>\n</div>"
"<div>\n<div>\n<div class=\"gl-relative markdown-code-block js-markdown-code\">\n<pre class=\"code highlight js-syntax-highlight language-plaintext\" lang=\"plaintext\" v-pre=\"true\"><code><span id=\"LC1\" class=\"line\" lang=\"plaintext\">\"&gt;</span></code></pre>\n<copy-code></copy-code>\n</div>\n</div>\n</div>"
end
expect(render(input, context)).to include(output)
......@@ -365,7 +365,10 @@ module Gitlab
output = <<~HTML
<div>
<div>
<div class="gl-relative markdown-code-block js-markdown-code">
<pre class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true"><code><span id="LC1" class="line" lang="javascript"><span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">hello world</span><span class="dl">'</span><span class="p">)</span></span></code></pre>
<copy-code></copy-code>
</div>
</div>
</div>
HTML
......@@ -392,11 +395,14 @@ module Gitlab
<div>
<div>class.cpp</div>
<div>
<div class="gl-relative markdown-code-block js-markdown-code">
<pre class="code highlight js-syntax-highlight language-cpp" lang="cpp" v-pre="true"><code><span id="LC1" class="line" lang="cpp"><span class="cp">#include &lt;stdio.h&gt;</span></span>
<span id="LC2" class="line" lang="cpp"></span>
<span id="LC3" class="line" lang="cpp"><span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="mi">5</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span></span>
<span id="LC4" class="line" lang="cpp"> <span class="n">std</span><span class="o">::</span><span class="n">cout</span><span class="o">&lt;&lt;</span><span class="s">"*"</span><span class="o">&lt;&lt;</span><span class="n">std</span><span class="o">::</span><span class="n">endl</span><span class="p">;</span></span>
<span id="LC5" class="line" lang="cpp"><span class="p">}</span></span></code></pre>
<copy-code></copy-code>
</div>
</div>
</div>
HTML
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment