summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/copy_as_gfm.js.es6319
-rw-r--r--app/assets/javascripts/shortcuts_issuable.js48
-rw-r--r--changelogs/unreleased/copy-as-md.yml4
-rw-r--r--lib/banzai/filter/abstract_reference_filter.rb5
-rw-r--r--lib/banzai/filter/issue_reference_filter.rb2
-rw-r--r--lib/banzai/filter/syntax_highlight_filter.rb5
-rw-r--r--lib/banzai/filter/video_link_filter.rb3
-rw-r--r--lib/banzai/pipeline/gfm_pipeline.rb3
-rw-r--r--spec/features/copy_as_gfm_spec.rb234
9 files changed, 595 insertions, 28 deletions
diff --git a/app/assets/javascripts/copy_as_gfm.js.es6 b/app/assets/javascripts/copy_as_gfm.js.es6
new file mode 100644
index 00000000000..4827f7b7420
--- /dev/null
+++ b/app/assets/javascripts/copy_as_gfm.js.es6
@@ -0,0 +1,319 @@
+/* eslint-disable class-methods-use-this */
+
+(() => {
+ const gfmRules = {
+ // Should have an entry for every filter in lib/banzai/pipeline/gfm_pipeline.rb,
+ // in reverse order.
+ // Should have test coverage in spec/features/copy_as_gfm_spec.rb.
+ "InlineDiffFilter": {
+ "span.idiff.addition": function(el, text) {
+ return "{+" + text + "+}";
+ },
+ "span.idiff.deletion": function(el, text) {
+ return "{-" + text + "-}";
+ },
+ },
+ "TaskListFilter": {
+ "input[type=checkbox].task-list-item-checkbox": function(el, text) {
+ return '[' + (el.checked ? 'x' : ' ') + ']';
+ }
+ },
+ "ReferenceFilter": {
+ "a.gfm:not([data-link=true])": function(el, text) {
+ return el.getAttribute('data-original') || text;
+ },
+ },
+ "AutolinkFilter": {
+ "a": function(el, text) {
+ if (text != el.getAttribute("href")) {
+ // Fall back to handler for MarkdownFilter
+ return false;
+ }
+
+ return text;
+ },
+ },
+ "TableOfContentsFilter": {
+ "ul.section-nav": function(el, text) {
+ return "[[_TOC_]]";
+ },
+ },
+ "EmojiFilter": {
+ "img.emoji": function(el, text) {
+ return el.getAttribute("alt");
+ },
+ },
+ "ImageLinkFilter": {
+ "a.no-attachment-icon": function(el, text) {
+ return text;
+ },
+ },
+ "VideoLinkFilter": {
+ ".video-container": function(el, text) {
+ var videoEl = el.querySelector('video');
+ if (!videoEl) {
+ return false;
+ }
+
+ return CopyAsGFM.nodeToGFM(videoEl);
+ },
+ "video": function(el, text) {
+ return "![" + el.getAttribute('data-title') + "](" + el.getAttribute("src") + ")";
+ },
+ },
+ "MathFilter": {
+ "pre.code.math[data-math-style='display']": function(el, text) {
+ return "```math\n" + text.trim() + "\n```";
+ },
+ "code.code.math[data-math-style='inline']": function(el, text) {
+ return "$`" + text + "`$";
+ },
+ "span.katex-display span.katex-mathml": function(el, text) {
+ var mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
+ if (!mathAnnotation) {
+ return false;
+ }
+
+ return "```math\n" + CopyAsGFM.nodeToGFM(mathAnnotation) + "\n```";
+ },
+ "span.katex-mathml": function(el, text) {
+ var mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
+ if (!mathAnnotation) {
+ return false;
+ }
+
+ return "$`" + CopyAsGFM.nodeToGFM(mathAnnotation) + "`$";
+ },
+ "span.katex-html": function(el, text) {
+ return "";
+ },
+ 'annotation[encoding="application/x-tex"]': function(el, text) {
+ return text.trim();
+ }
+ },
+ "SyntaxHighlightFilter": {
+ "pre.code.highlight": function(el, text) {
+ var lang = el.getAttribute("lang");
+ if (lang == "text") {
+ lang = "";
+ }
+ return "```" + lang + "\n" + text.trim() + "\n```";
+ },
+ "pre > code": function(el, text) {
+ // Don't wrap code blocks in ``
+ return text;
+ },
+ },
+ "MarkdownFilter": {
+ "code": function(el, text) {
+ var backtickCount = 1;
+ var backtickMatch = text.match(/`+/);
+ if (backtickMatch) {
+ backtickCount = backtickMatch[0].length + 1;
+ }
+
+ var backticks = new Array(backtickCount + 1).join('`');
+ var spaceOrNoSpace = backtickCount > 1 ? " " : "";
+
+ return backticks + spaceOrNoSpace + text + spaceOrNoSpace + backticks;
+ },
+ "blockquote": function(el, text) {
+ return text.trim().split('\n').map(function(s) { return ('> ' + s).trim(); }).join('\n');
+ },
+ "img": function(el, text) {
+ return "![" + el.getAttribute("alt") + "](" + el.getAttribute("src") + ")";
+ },
+ "a.anchor": function(el, text) {
+ return text;
+ },
+ "a": function(el, text) {
+ return "[" + text + "](" + el.getAttribute("href") + ")";
+ },
+ "li": function(el, text) {
+ var lines = text.trim().split('\n');
+ var firstLine = '- ' + lines.shift();
+ var nextLines = lines.map(function(s) { return (' ' + s).replace(/\s+$/, ''); });
+
+ return firstLine + '\n' + nextLines.join('\n');
+ },
+ "ul": function(el, text) {
+ return text;
+ },
+ "ol": function(el, text) {
+ return text.replace(/^- /mg, '1. ');
+ },
+ "h1": function(el, text) {
+ return '# ' + text.trim();
+ },
+ "h2": function(el, text) {
+ return '## ' + text.trim();
+ },
+ "h3": function(el, text) {
+ return '### ' + text.trim();
+ },
+ "h4": function(el, text) {
+ return '#### ' + text.trim();
+ },
+ "h5": function(el, text) {
+ return '##### ' + text.trim();
+ },
+ "h6": function(el, text) {
+ return '###### ' + text.trim();
+ },
+ "strong": function(el, text) {
+ return '**' + text + '**';
+ },
+ "em": function(el, text) {
+ return '_' + text + '_';
+ },
+ "del": function(el, text) {
+ return '~~' + text + '~~';
+ },
+ "sup": function(el, text) {
+ return '^' + text;
+ },
+ "hr": function(el, text) {
+ return '-----';
+ },
+ "table": function(el, text) {
+ var theadText = CopyAsGFM.nodeToGFM(el.querySelector('thead'));
+ var tbodyText = CopyAsGFM.nodeToGFM(el.querySelector('tbody'));
+
+ return theadText + tbodyText;
+ },
+ "thead": function(el, text) {
+ var cells = _.map(el.querySelectorAll('th'), function(cell) {
+ var chars = CopyAsGFM.nodeToGFM(cell).trim().length;
+
+ var before = '';
+ var after = '';
+ switch (cell.style.textAlign) {
+ case 'center':
+ before = ':';
+ after = ':';
+ chars -= 2;
+ break;
+ case 'right':
+ after = ':';
+ chars -= 1;
+ break;
+ }
+
+ chars = Math.max(chars, 0);
+
+ var middle = new Array(chars + 1).join('-');
+
+ return before + middle + after;
+ });
+ return text + '| ' + cells.join(' | ') + ' |';
+ },
+ "tr": function(el, text) {
+ var cells = _.map(el.querySelectorAll('td, th'), function(cell) {
+ return CopyAsGFM.nodeToGFM(cell).trim();
+ });
+ return '| ' + cells.join(' | ') + ' |';
+ },
+ }
+ };
+
+ class CopyAsGFM {
+ constructor() {
+ $(document).on('copy', '.md, .wiki', this.handleCopy.bind(this));
+ $(document).on('paste', '.js-gfm-input', this.handlePaste.bind(this));
+ }
+
+ handleCopy(e) {
+ var clipboardData = e.originalEvent.clipboardData;
+ if (!clipboardData) return;
+
+ if (!window.getSelection) return;
+
+ var selection = window.getSelection();
+ if (!selection.rangeCount || selection.rangeCount === 0) return;
+
+ var selectedDocument = selection.getRangeAt(0).cloneContents();
+ if (!selectedDocument) return;
+
+ e.preventDefault();
+ clipboardData.setData('text/plain', selectedDocument.textContent);
+
+ var gfm = CopyAsGFM.nodeToGFM(selectedDocument);
+ clipboardData.setData('text/x-gfm', gfm);
+ }
+
+ handlePaste(e) {
+ var clipboardData = e.originalEvent.clipboardData;
+ if (!clipboardData) return;
+
+ var gfm = clipboardData.getData('text/x-gfm');
+ if (!gfm) return;
+
+ e.preventDefault();
+
+ this.insertText(e.target, gfm);
+ }
+
+ insertText(target, text) {
+ // Firefox doesn't support `document.execCommand('insertText', false, text);` on textareas
+ var selectionStart = target.selectionStart;
+ var selectionEnd = target.selectionEnd;
+ var value = target.value;
+ var textBefore = value.substring(0, selectionStart);
+ var textAfter = value.substring(selectionEnd, value.length);
+ var newText = textBefore + text + textAfter;
+ target.value = newText;
+ target.selectionStart = target.selectionEnd = selectionStart + text.length;
+ }
+
+ static nodeToGFM(node) {
+ if (node.nodeType == Node.TEXT_NODE) {
+ return node.textContent;
+ }
+
+ var text = this.innerGFM(node);
+
+ if (node.nodeType == Node.DOCUMENT_FRAGMENT_NODE) {
+ return text;
+ }
+
+ for (var filter in gfmRules) {
+ var rules = gfmRules[filter];
+
+ for (var selector in rules) {
+ var func = rules[selector];
+
+ if (!node.matches(selector)) continue;
+
+ var result = func(node, text);
+ if (result === false) continue;
+
+ return result;
+ }
+ }
+
+ return text;
+ }
+
+ static innerGFM(parentNode) {
+ var nodes = parentNode.childNodes;
+
+ var clonedParentNode = parentNode.cloneNode(true);
+ var clonedNodes = Array.prototype.slice.call(clonedParentNode.childNodes, 0);
+
+ for (var i = 0; i < nodes.length; i++) {
+ var node = nodes[i];
+ var clonedNode = clonedNodes[i];
+
+ var text = this.nodeToGFM(node);
+ clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode);
+ }
+
+ return clonedParentNode.innerText || clonedParentNode.textContent;
+ }
+ }
+
+ window.gl = window.gl || {};
+ window.gl.CopyAsGFM = CopyAsGFM;
+
+ new CopyAsGFM();
+})();
diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js
index b892fbc3393..426821873d7 100644
--- a/app/assets/javascripts/shortcuts_issuable.js
+++ b/app/assets/javascripts/shortcuts_issuable.js
@@ -39,29 +39,33 @@
}
ShortcutsIssuable.prototype.replyWithSelectedText = function() {
- var quote, replyField, selected, separator;
- if (window.getSelection) {
- selected = window.getSelection().toString();
- replyField = $('.js-main-target-form #note_note');
- if (selected.trim() === "") {
- return;
- }
- // Put a '>' character before each non-empty line in the selection
- quote = _.map(selected.split("\n"), function(val) {
- if (val.trim() !== '') {
- return "> " + val + "\n";
- }
- });
- // If replyField already has some content, add a newline before our quote
- separator = replyField.val().trim() !== "" && "\n" || '';
- replyField.val(function(_, current) {
- return current + separator + quote.join('') + "\n";
- });
- // Trigger autosave for the added text
- replyField.trigger('input');
- // Focus the input field
- return replyField.focus();
+ var quote, replyField, selectedDocument, selected, selection, separator;
+ if (!window.getSelection) return;
+
+ selection = window.getSelection();
+ if (!selection.rangeCount || selection.rangeCount === 0) return;
+
+ selectedDocument = selection.getRangeAt(0).cloneContents();
+ if (!selectedDocument) return;
+
+ selected = window.gl.CopyAsGFM.nodeToGFM(selectedDocument);
+
+ replyField = $('.js-main-target-form #note_note');
+ if (selected.trim() === "") {
+ return;
}
+ quote = _.map(selected.split("\n"), function(val) {
+ return "> " + val + "\n";
+ });
+ // If replyField already has some content, add a newline before our quote
+ separator = replyField.val().trim() !== "" && "\n" || '';
+ replyField.val(function(_, current) {
+ return current + separator + quote.join('') + "\n";
+ });
+ // Trigger autosave for the added text
+ replyField.trigger('input');
+ // Focus the input field
+ return replyField.focus();
};
ShortcutsIssuable.prototype.editIssue = function() {
diff --git a/changelogs/unreleased/copy-as-md.yml b/changelogs/unreleased/copy-as-md.yml
new file mode 100644
index 00000000000..637e9dc36e2
--- /dev/null
+++ b/changelogs/unreleased/copy-as-md.yml
@@ -0,0 +1,4 @@
+---
+title: Copying a rendered issue/comment will paste into GFM textareas as actual GFM
+merge_request:
+author:
diff --git a/lib/banzai/filter/abstract_reference_filter.rb b/lib/banzai/filter/abstract_reference_filter.rb
index 6d04f68c8f9..a3d495a5da0 100644
--- a/lib/banzai/filter/abstract_reference_filter.rb
+++ b/lib/banzai/filter/abstract_reference_filter.rb
@@ -153,7 +153,7 @@ module Banzai
title = object_link_title(object)
klass = reference_class(object_sym)
- data = data_attributes_for(link_content || match, project, object)
+ data = data_attributes_for(link_content || match, project, object, link: !!link_content)
if matches.names.include?("url") && matches[:url]
url = matches[:url]
@@ -172,9 +172,10 @@ module Banzai
end
end
- def data_attributes_for(text, project, object)
+ def data_attributes_for(text, project, object, link: false)
data_attribute(
original: text,
+ link: link,
project: project.id,
object_sym => object.id
)
diff --git a/lib/banzai/filter/issue_reference_filter.rb b/lib/banzai/filter/issue_reference_filter.rb
index 4d1bc687696..fd6b9704132 100644
--- a/lib/banzai/filter/issue_reference_filter.rb
+++ b/lib/banzai/filter/issue_reference_filter.rb
@@ -62,7 +62,7 @@ module Banzai
end
end
- def data_attributes_for(text, project, object)
+ def data_attributes_for(text, project, object, link: false)
if object.is_a?(ExternalIssue)
data_attribute(
project: project.id,
diff --git a/lib/banzai/filter/syntax_highlight_filter.rb b/lib/banzai/filter/syntax_highlight_filter.rb
index 026b81ac175..933103abb92 100644
--- a/lib/banzai/filter/syntax_highlight_filter.rb
+++ b/lib/banzai/filter/syntax_highlight_filter.rb
@@ -20,17 +20,18 @@ module Banzai
code = node.text
css_classes = "code highlight"
lexer = lexer_for(language)
+ lang = lexer.tag
begin
code = format(lex(lexer, code))
- css_classes << " js-syntax-highlight #{lexer.tag}"
+ css_classes << " js-syntax-highlight #{lang}"
rescue
# Gracefully handle syntax highlighter bugs/errors to ensure
# users can still access an issue/comment/etc.
end
- highlighted = %(<pre class="#{css_classes}" v-pre="true"><code>#{code}</code></pre>)
+ highlighted = %(<pre class="#{css_classes}" lang="#{lang}" v-pre="true"><code>#{code}</code></pre>)
# Extracted to a method to measure it
replace_parent_pre_element(node, highlighted)
diff --git a/lib/banzai/filter/video_link_filter.rb b/lib/banzai/filter/video_link_filter.rb
index ac7bbcb0d10..b64a1287d4d 100644
--- a/lib/banzai/filter/video_link_filter.rb
+++ b/lib/banzai/filter/video_link_filter.rb
@@ -35,7 +35,8 @@ module Banzai
src: element['src'],
width: '400',
controls: true,
- 'data-setup' => '{}')
+ 'data-setup' => '{}',
+ 'data-title' => element['title'] || element['alt'])
link = doc.document.create_element(
'a',
diff --git a/lib/banzai/pipeline/gfm_pipeline.rb b/lib/banzai/pipeline/gfm_pipeline.rb
index 5a1f873496c..09038d38b1f 100644
--- a/lib/banzai/pipeline/gfm_pipeline.rb
+++ b/lib/banzai/pipeline/gfm_pipeline.rb
@@ -1,6 +1,9 @@
module Banzai
module Pipeline
class GfmPipeline < BasePipeline
+ # Every filter should have an entry in app/assets/javascripts/copy_as_gfm.js.es6,
+ # in reverse order.
+ # Should have test coverage in spec/features/copy_as_gfm_spec.rb.
def self.filters
@filters ||= FilterArray[
Filter::SyntaxHighlightFilter,
diff --git a/spec/features/copy_as_gfm_spec.rb b/spec/features/copy_as_gfm_spec.rb
new file mode 100644
index 00000000000..8e8c0ecb282
--- /dev/null
+++ b/spec/features/copy_as_gfm_spec.rb
@@ -0,0 +1,234 @@
+require 'spec_helper'
+
+describe 'Copy as GFM', feature: true, js: true do
+ include GitlabMarkdownHelper
+ include ActionView::Helpers::JavaScriptHelper
+
+ before do
+ @feat = MarkdownFeature.new
+
+ # `markdown` helper expects a `@project` variable
+ @project = @feat.project
+
+ visit namespace_project_issue_path(@project.namespace, @project, @feat.issue)
+ end
+
+ # Should have an entry for every filter in lib/banzai/pipeline/gfm_pipeline.rb
+ # and app/assets/javascripts/copy_as_gfm.js.es6
+ filters = {
+ 'any filter' => [
+ [
+ 'crazy nesting',
+ '> 1. [x] **[$`2 + 2`$ {-=-}{+=+} 2^2 ~~:thumbsup:~~](http://google.com)**'
+ ],
+ [
+ 'real world example from the gitlab-ce README',
+ <<-GFM.strip_heredoc
+ # GitLab
+
+ [![Build status](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/build.svg)](https://gitlab.com/gitlab-org/gitlab-ce/commits/master)
+ [![CE coverage report](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg?job=coverage)](https://gitlab-org.gitlab.io/gitlab-ce/coverage-ruby)
+ [![Code Climate](https://codeclimate.com/github/gitlabhq/gitlabhq.svg)](https://codeclimate.com/github/gitlabhq/gitlabhq)
+ [![Core Infrastructure Initiative Best Practices](https://bestpractices.coreinfrastructure.org/projects/42/badge)](https://bestpractices.coreinfrastructure.org/projects/42)
+
+ ## Canonical source
+
+ The canonical source of GitLab Community Edition is [hosted on GitLab.com](https://gitlab.com/gitlab-org/gitlab-ce/).
+
+ ## Open source software to collaborate on code
+
+ To see how GitLab looks please see the [features page on our website](https://about.gitlab.com/features/).
+
+
+ - Manage Git repositories with fine grained access controls that keep your code secure
+
+ - Perform code reviews and enhance collaboration with merge requests
+
+ - Complete continuous integration (CI) and CD pipelines to builds, test, and deploy your applications
+
+ - Each project can also have an issue tracker, issue board, and a wiki
+
+ - Used by more than 100,000 organizations, GitLab is the most popular solution to manage Git repositories on-premises
+
+ - Completely free and open source (MIT Expat license)
+ GFM
+ ]
+ ],
+ 'InlineDiffFilter' => [
+ '{-Deleted text-}',
+ '{+Added text+}'
+ ],
+ 'TaskListFilter' => [
+ '- [ ] Unchecked task',
+ '- [x] Checked task',
+ '1. [ ] Unchecked numbered task',
+ '1. [x] Checked numbered task'
+ ],
+ 'ReferenceFilter' => [
+ ['issue reference', -> { @feat.issue.to_reference }],
+ ['full issue reference', -> { @feat.issue.to_reference(full: true) }],
+ ['issue URL', -> { namespace_project_issue_url(@project.namespace, @project, @feat.issue) }],
+ ['issue URL with note anchor', -> { namespace_project_issue_url(@project.namespace, @project, @feat.issue, anchor: 'note_123') }],
+ ['issue link', -> { "[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue)})" }],
+ ['issue link with note anchor', -> { "[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue, anchor: 'note_123')})" }],
+ ],
+ 'AutolinkFilter' => [
+ 'https://example.com'
+ ],
+ 'TableOfContentsFilter' => [
+ '[[_TOC_]]'
+ ],
+ 'EmojiFilter' => [
+ ':thumbsup:'
+ ],
+ 'ImageLinkFilter' => [
+ '![Image](https://example.com/image.png)'
+ ],
+ 'VideoLinkFilter' => [
+ '![Video](https://example.com/video.mp4)'
+ ],
+ 'MathFilter' => [
+ '$`c = \pm\sqrt{a^2 + b^2}`$',
+ [
+ 'math block',
+ <<-GFM.strip_heredoc
+ ```math
+ c = \pm\sqrt{a^2 + b^2}
+ ```
+ GFM
+ ]
+ ],
+ 'SyntaxHighlightFilter' => [
+ [
+ 'code block',
+ <<-GFM.strip_heredoc
+ ```ruby
+ def foo
+ bar
+ end
+ ```
+ GFM
+ ]
+ ],
+ 'MarkdownFilter' => [
+ '`code`',
+ '`` code with ` ticks ``',
+
+ '> Quote',
+ [
+ 'multiline quote',
+ <<-GFM.strip_heredoc,
+ > Multiline
+ > Quote
+ >
+ > With multiple paragraphs
+ GFM
+ ],
+
+ '![Image](https://example.com/image.png)',
+
+ '# Heading with no anchor link',
+
+ '[Link](https://example.com)',
+
+ '- List item',
+ [
+ 'multiline list item',
+ <<-GFM.strip_heredoc,
+ - Multiline
+ List item
+ GFM
+ ],
+ [
+ 'nested lists',
+ <<-GFM.strip_heredoc,
+ - Nested
+
+
+ - Lists
+ GFM
+ ],
+ '1. Numbered list item',
+ [
+ 'multiline numbered list item',
+ <<-GFM.strip_heredoc,
+ 1. Multiline
+ Numbered list item
+ GFM
+ ],
+ [
+ 'nested numbered list',
+ <<-GFM.strip_heredoc,
+ 1. Nested
+
+
+ 1. Numbered lists
+ GFM
+ ],
+
+ '# Heading',
+ '## Heading',
+ '### Heading',
+ '#### Heading',
+ '##### Heading',
+ '###### Heading',
+
+ '**Bold**',
+
+ '_Italics_',
+
+ '~~Strikethrough~~',
+
+ '2^2',
+
+ '-----',
+
+ [
+ 'table',
+ <<-GFM.strip_heredoc,
+ | Centered | Right | Left |
+ | :------: | ----: | ---- |
+ | Foo | Bar | **Baz** |
+ | Foo | Bar | **Baz** |
+ GFM
+ ]
+ ]
+ }
+
+ filters.each do |filter, examples|
+ context filter do
+ examples.each do |ex|
+ if ex.is_a?(String)
+ desc = "'#{ex}'"
+ gfm = ex
+ else
+ desc, gfm = ex
+ end
+
+ it "transforms #{desc} to HTML and back to GFM" do
+ gfm = instance_exec(&gfm) if gfm.is_a?(Proc)
+
+ html = markdown(gfm)
+ gfm2 = html_to_gfm(html)
+ expect(gfm2.strip).to eq(gfm.strip)
+ end
+ end
+ end
+ end
+
+ def html_to_gfm(html)
+ js = <<-JS.strip_heredoc
+ (function(html) {
+ var node = document.createElement('div');
+ node.innerHTML = html;
+ return window.gl.CopyAsGFM.nodeToGFM(node);
+ })("#{escape_javascript(html)}")
+ JS
+ page.evaluate_script(js)
+ end
+
+ # Fake a `current_user` helper
+ def current_user
+ @feat.user
+ end
+end