Commit dbfa58e2 authored by Douwe Maan's avatar Douwe Maan

Copying a rendered issue/comment will paste into GFM textareas as actual GFM

parent 142be72a
/* 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();
})();
......@@ -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() {
......
---
title: Copying a rendered issue/comment will paste into GFM textareas as actual GFM
merge_request:
author:
......@@ -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
)
......
......@@ -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,
......
......@@ -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)
......
......@@ -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',
......
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,
......
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
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