Skip to content
Snippets Groups Projects
Unverified Commit 30e2ff9c authored by Douwe Maan's avatar Douwe Maan
Browse files

WIP: Use Prosemirror schema for Copy as GFM

parent 65dd3ebb
No related branches found
No related tags found
No related merge requests found
Loading
Loading
@@ -4,168 +4,22 @@ import $ from 'jquery';
import _ from 'underscore';
import { insertText, getSelectedFragment, nodeMatchesSelector } from '~/lib/utils/common_utils';
import { placeholderImage } from '~/lazy_loader';
import schema from '~/vue_shared/components/markdown/schema'
import markdownSerializer from '~/vue_shared/components/markdown/markdown_serializer';
import { DOMParser } from 'prosemirror-model'
 
const gfmRules = {
// The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert
// GitLab Flavored Markdown (GFM) to HTML.
// These handlers consequently convert that same HTML to GFM to be copied to the clipboard.
// Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML
// from GFM should have a handler here, in reverse order.
// The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb.
InlineDiffFilter: {
'span.idiff.addition'(el, text) {
return `{+${text}+}`;
},
'span.idiff.deletion'(el, text) {
return `{-${text}-}`;
},
},
TaskListFilter: {
'input[type=checkbox].task-list-item-checkbox'(el) {
return `[${el.checked ? 'x' : ' '}]`;
},
},
ReferenceFilter: {
'.tooltip'(el) {
return '';
},
'a.gfm:not([data-link=true])'(el, text) {
return el.dataset.original || text;
},
},
AutolinkFilter: {
'a'(el, text) {
// Fallback on the regular MarkdownFilter's `a` handler.
if (text !== el.getAttribute('href')) return false;
return text;
},
},
TableOfContentsFilter: {
'ul.section-nav'(el) {
return '[[_TOC_]]';
},
},
EmojiFilter: {
'img.emoji'(el) {
return el.getAttribute('alt');
},
'gl-emoji'(el) {
return `:${el.getAttribute('data-name')}:`;
},
},
ImageLinkFilter: {
'a.no-attachment-icon'(el, text) {
return text;
},
},
VideoLinkFilter: {
'.video-container'(el) {
const videoEl = el.querySelector('video');
if (!videoEl) return false;
return CopyAsGFM.nodeToGFM(videoEl);
},
'video'(el) {
return `![${el.dataset.title}](${el.getAttribute('src')})`;
},
},
MermaidFilter: {
'svg.mermaid'(el, text) {
const sourceEl = el.querySelector('text.source');
if (!sourceEl) return false;
return `\`\`\`mermaid\n${CopyAsGFM.nodeToGFM(sourceEl)}\n\`\`\``;
},
'svg.mermaid style, svg.mermaid g'(el, text) {
// We don't want to include the content of these elements in the copied text.
return '';
},
},
MathFilter: {
'pre.code.math[data-math-style=display]'(el, text) {
return `\`\`\`math\n${text.trim()}\n\`\`\``;
},
'code.code.math[data-math-style=inline]'(el, text) {
return `$\`${text}\`$`;
},
'span.katex-display span.katex-mathml'(el) {
const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
if (!mathAnnotation) return false;
return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``;
},
'span.katex-mathml'(el) {
const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
if (!mathAnnotation) return false;
return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`;
},
'span.katex-html'(el) {
// We don't want to include the content of this element in the copied text.
return '';
},
'annotation[encoding="application/x-tex"]'(el, text) {
return text.trim();
},
},
SanitizationFilter: {
'a[name]:not([href]):empty'(el) {
return el.outerHTML;
},
'dl'(el, text) {
let lines = text.replace(/\n\n/g, '\n').trim().split('\n');
// Add two spaces to the front of subsequent list items lines,
// or leave the line entirely blank.
lines = lines.map((l) => {
const line = l.trim();
if (line.length === 0) return '';
return ` ${line}`;
});
return `<dl>\n${lines.join('\n')}\n</dl>\n`;
},
'dt, dd, summary, details'(el, text) {
const tag = el.nodeName.toLowerCase();
return `<${tag}>${text}</${tag}>\n`;
},
'sup, sub, kbd, q, samp, var, ruby, rt, rp, abbr'(el, text) {
const tag = el.nodeName.toLowerCase();
return `<${tag}>${text}</${tag}>`;
},
},
SyntaxHighlightFilter: {
'pre.code.highlight'(el, t) {
const text = t.trimRight();
let lang = el.getAttribute('lang');
if (!lang || lang === 'plaintext') {
lang = '';
}
// Prefixes lines with 4 spaces if the code contains triple backticks
if (lang === '' && text.match(/^```/gm)) {
return text.split('\n').map((l) => {
const line = l.trim();
if (line.length === 0) return '';
return ` ${line}`;
}).join('\n');
}
return `\`\`\`${lang}\n${text}\n\`\`\``;
},
'pre > code'(el, text) {
// Don't wrap code blocks in ``
return text;
},
},
MarkdownFilter: {
'br'(el) {
// Two spaces at the end of a line are turned into a BR
return ' ';
},
'code'(el, text) {
let backtickCount = 1;
const backtickMatch = text.match(/`+/);
Loading
Loading
@@ -178,76 +32,6 @@ const gfmRules = {
 
return backticks + spaceOrNoSpace + text.trim() + spaceOrNoSpace + backticks;
},
'blockquote'(el, text) {
return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n');
},
'img'(el) {
const imageSrc = el.src;
const imageUrl = imageSrc && imageSrc !== placeholderImage ? imageSrc : (el.dataset.src || '');
return `![${el.getAttribute('alt')}](${imageUrl})`;
},
'a.anchor'(el, text) {
// Don't render a Markdown link for the anchor link inside a heading
return text;
},
'a'(el, text) {
return `[${text}](${el.getAttribute('href')})`;
},
'li'(el, text) {
const lines = text.trim().split('\n');
const firstLine = `- ${lines.shift()}`;
// Add four spaces to the front of subsequent list items lines,
// or leave the line entirely blank.
const nextLines = lines.map((s) => {
if (s.trim().length === 0) return '';
return ` ${s}`;
});
return `${firstLine}\n${nextLines.join('\n')}`;
},
'ul'(el, text) {
return text;
},
'ol'(el, text) {
// LIs get a `- ` prefix by default, which we replace by `1. ` for ordered lists.
return text.replace(/^- /mg, '1. ');
},
'h1'(el, text) {
return `# ${text.trim()}\n`;
},
'h2'(el, text) {
return `## ${text.trim()}\n`;
},
'h3'(el, text) {
return `### ${text.trim()}\n`;
},
'h4'(el, text) {
return `#### ${text.trim()}\n`;
},
'h5'(el, text) {
return `##### ${text.trim()}\n`;
},
'h6'(el, text) {
return `###### ${text.trim()}\n`;
},
'strong'(el, text) {
return `**${text}**`;
},
'em'(el, text) {
return `_${text}_`;
},
'del'(el, text) {
return `~~${text}~~`;
},
'hr'(el) {
// extra leading \n is to ensure that there is a blank line between
// a list followed by an hr, otherwise this breaks old redcarpet rendering
return '\n-----\n';
},
'p'(el, text) {
return `${text.trim()}\n`;
},
'table'(el) {
const theadEl = el.querySelector('thead');
const tbodyEl = el.querySelector('tbody');
Loading
Loading
@@ -429,73 +213,11 @@ export class CopyAsGFM {
}
 
static nodeToGFM(node, respectWhitespaceParam = false) {
if (node.nodeType === Node.COMMENT_NODE) {
return '';
}
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent;
}
const respectWhitespace = respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE');
const text = this.innerGFM(node, respectWhitespace);
if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
return text;
}
for (const filter in gfmRules) {
const rules = gfmRules[filter];
for (const selector in rules) {
const func = rules[selector];
if (!nodeMatchesSelector(node, selector)) continue;
let result;
if (func.length === 2) {
// if `func` takes 2 arguments, it depends on text.
// if there is no text, we don't need to generate GFM for this node.
if (text.length === 0) continue;
result = func(node, text);
} else {
result = func(node);
}
if (result === false) continue;
return result;
}
}
return text;
}
static innerGFM(parentNode, respectWhitespace = false) {
const nodes = parentNode.childNodes;
const clonedParentNode = parentNode.cloneNode(true);
const clonedNodes = Array.prototype.slice.call(clonedParentNode.childNodes, 0);
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i];
const clonedNode = clonedNodes[i];
const text = this.nodeToGFM(node, respectWhitespace);
// `clonedNode.replaceWith(text)` is not yet widely supported
clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode);
}
let nodeText = clonedParentNode.innerText || clonedParentNode.textContent;
if (!respectWhitespace) {
nodeText = nodeText.trim();
}
const wrapEl = document.createElement('div');
wrapEl.appendChild(node);
const doc = DOMParser.fromSchema(schema).parse(wrapEl);
 
return nodeText;
return markdownSerializer.serialize(doc);
}
}
 
Loading
Loading
Loading
Loading
@@ -36,18 +36,18 @@ export default class ShortcutsIssuable extends Shortcuts {
}
 
const el = CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true));
const selected = CopyAsGFM.nodeToGFM(el);
const blockquoteEl = document.createElement('blockquote');
blockquoteEl.appendChild(el);
const text = CopyAsGFM.nodeToGFM(blockquoteEl);
 
if (selected.trim() === '') {
if (text.trim() === '') {
return false;
}
 
const quote = _.map(selected.split('\n'), val => `${`> ${val}`.trim()}\n`);
// If replyField already has some content, add a newline before our quote
const separator = ($replyField.val().trim() !== '' && '\n\n') || '';
$replyField
.val((a, current) => `${current}${separator}${quote.join('')}\n`)
.val((a, current) => `${current}${separator}${text}\n`)
.trigger('input')
.trigger('change');
 
Loading
Loading
import {
HistoryExtension,
PlaceholderExtension,
BoldMark,
ItalicMark,
LinkMark,
BulletListNode,
HardBreakNode,
HeadingNode,
ListItemNode,
OrderedListNode,
} from 'tiptap-extensions'
import InlineDiffMark from './marks/inline_diff';
import InlineHTMLMark from './marks/inline_html';
import StrikeMark from './marks/strike';
import CodeMark from './marks/code';
import MathMark from './marks/math';
import EmojiNode from './nodes/emoji';
import HorizontalRuleNode from './nodes/horizontal_rule.js';
import ReferenceNode from './nodes/reference';
import BlockquoteNode from './nodes/blockquote';
import CodeBlockNode from './nodes/code_block';
import ImageNode from './nodes/image';
import VideoNode from './nodes/video';
import DetailsNode from './nodes/details';
import SummaryNode from './nodes/summary';
export default [
new HistoryExtension,
new PlaceholderExtension,
new EmojiNode,
new VideoNode,
new DetailsNode,
new SummaryNode,
new ReferenceNode,
new HorizontalRuleNode,
// new TableOfContentsNode,
// new TableNode,
// new TableHeadNode,
// new TableRowNode,
// new TableCellNode,
// new TaskItemNode,
// new TaskListNode,
new BlockquoteNode,
new BulletListNode,
new CodeBlockNode,
new HeadingNode({ maxLevel: 6 }),
new HardBreakNode,
new ImageNode,
new ListItemNode,
new OrderedListNode,
new BoldMark,
new LinkMark,
new ItalicMark,
new StrikeMark,
new InlineDiffMark,
new InlineHTMLMark,
new MathMark,
new CodeMark,
// new SuggestionsPlugin,
// new MentionNode,
]
<script>
import $ from 'jquery';
import { Editor } from 'tiptap'
import {
HistoryExtension,
PlaceholderExtension,
BoldMark,
ItalicMark,
LinkMark,
BulletListNode,
HardBreakNode,
HeadingNode,
ListItemNode,
OrderedListNode,
} from 'tiptap-extensions'
import { s__ } from '~/locale';
import Flash from '../../../flash';
import GLForm from '../../../gl_form';
Loading
Loading
@@ -23,20 +9,7 @@
import markdownToolbar from './toolbar.vue';
import icon from '../icon.vue';
import { updateMarkdownText } from '../../../lib/utils/text_markdown';
import InlineDiffMark from './marks/inline_diff';
import InlineHTMLMark from './marks/inline_html';
import StrikeMark from './marks/strike';
import CodeMark from './marks/code';
import MathMark from './marks/math';
import EmojiNode from './nodes/emoji';
import HorizontalRuleNode from './nodes/horizontal_rule.js';
import ReferenceNode from './nodes/reference';
import BlockquoteNode from './nodes/blockquote';
import CodeBlockNode from './nodes/code_block';
import ImageNode from './nodes/image';
import VideoNode from './nodes/video';
import DetailsNode from './nodes/details';
import SummaryNode from './nodes/summary';
import editorExtensions from './editor_extensions';
import markdownSerializer from './markdown_serializer';
 
export default {
Loading
Loading
@@ -89,46 +62,7 @@
referencedUsers: '',
renderedLoading: false,
mode: 'markdown',
editorExtensions: [
new HistoryExtension,
new PlaceholderExtension,
// new TableOfContentsNode,
new EmojiNode,
new VideoNode,
new DetailsNode,
new SummaryNode,
new ReferenceNode,
new HorizontalRuleNode,
// new TableNode,
// new TableHeadNode,
// new TableRowNode,
// new TableCellNode,
// new TodoItemNode,
// new TodoListNode,
new BlockquoteNode,
new BulletListNode,
new CodeBlockNode,
new HeadingNode({ maxLevel: 6 }),
new HardBreakNode,
new ImageNode,
new ListItemNode,
new OrderedListNode,
new BoldMark,
new LinkMark,
new ItalicMark,
new StrikeMark,
new InlineDiffMark,
new InlineHTMLMark,
new MathMark,
new CodeMark,
// new SuggestionsPlugin,
// new MentionNode,
]
editorExtensions: editorExtensions
};
},
watch: {
Loading
Loading
Loading
Loading
@@ -8,7 +8,6 @@ export default class CodeMark extends Mark {
 
get schema() {
return {
excludes: '_',
parseDOM: [
{ tag: 'code' },
],
Loading
Loading
Loading
Loading
@@ -8,7 +8,6 @@ export default class MathMark extends Mark {
 
get schema() {
return {
excludes: '_',
parseDOM: [
{
tag: 'code.code.math[data-math-style=inline]',
Loading
Loading
import { Node } from 'tiptap'
import { setBlockType } from 'tiptap-commands'
import { Schema } from 'prosemirror-model'
import editorExtensions from './editor_extensions';
class DocNode extends Node {
get name() {
return 'doc'
}
get schema() {
return {
content: 'block+',
}
}
}
class ParagraphNode extends Node {
get name() {
return 'paragraph'
}
get schema() {
return {
content: 'inline*',
group: 'block',
draggable: false,
parseDOM: [{
tag: 'p',
}],
toDOM: () => ['p', 0],
}
}
command({ type }) {
return setBlockType(type)
}
}
class TextNode extends Node {
get name() {
return 'text'
}
get schema() {
return {
group: 'inline',
}
}
}
const builtInNodes = [
new DocNode(),
new ParagraphNode(),
new TextNode(),
];
const allExtensions = [
...builtInNodes,
...editorExtensions,
];
const nodes = allExtensions
.filter(extension => extension.type === 'node')
.reduce((nodes, { name, schema }) => ({
...nodes,
[name]: schema,
}), {})
const marks = allExtensions
.filter(extension => extension.type === 'mark')
.reduce((marks, { name, schema }) => ({
...marks,
[name]: schema,
}), {});
export default new Schema({
nodes: nodes,
marks: marks,
});
Loading
Loading
@@ -39,8 +39,11 @@ describe 'Copy as GFM', :js do
# 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
Loading
Loading
@@ -51,17 +54,17 @@ describe 'Copy as GFM', :js do
 
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
* Manage Git repositories with fine grained access controls that keep your code secure
 
- Perform code reviews and enhance collaboration with merge requests
* Perform code reviews and enhance collaboration with merge requests
 
- Complete continuous integration (CI) and CD pipelines to builds, test, and deploy your applications
* 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
* 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
* 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)
* Completely free and open source (MIT Expat license)
GFM
)
 
Loading
Loading
@@ -371,8 +374,7 @@ describe 'Copy as GFM', :js do
</g>
</g>
<text class="source" display="none">graph TD;
A--&gt;B;
</text>
A--&gt;B;</text>
</svg>
HTML
 
Loading
Loading
@@ -399,17 +401,14 @@ describe 'Copy as GFM', :js do
 
<var>var</var>
 
<ruby>ruby</ruby>
<rt>rt</rt>
<rp>rp</rp>
<abbr>abbr</abbr>
 
<summary>summary</summary>
<details>
<summary>summary></summary>
details
 
<details>details</details>
</details>
GFM
)
 
Loading
Loading
@@ -433,8 +432,6 @@ describe 'Copy as GFM', :js do
<<-GFM.strip_heredoc
Foo
 
This is an example of GFM
```js
Code goes here
```
Loading
Loading
@@ -453,8 +450,7 @@ describe 'Copy as GFM', :js do
 
# multiline quote
<<-GFM.strip_heredoc,
> Multiline
> Quote
> Multiline Quote
>
> With multiple paragraphs
GFM
Loading
Loading
@@ -465,26 +461,27 @@ describe 'Copy as GFM', :js do
 
'[Link](https://example.com)',
 
'- List item',
'* List item',
 
# multiline list item
<<-GFM.strip_heredoc,
- Multiline
List item
* Multiline
List item
GFM
 
# nested lists
<<-GFM.strip_heredoc,
- Nested
* Nested
 
- Lists
* Lists
GFM
 
# list with blockquote
<<-GFM.strip_heredoc,
- List
* List
 
> Blockquote
> Blockquote
GFM
 
'1. Numbered list item',
Loading
Loading
@@ -492,21 +489,22 @@ describe 'Copy as GFM', :js do
# multiline numbered list item
<<-GFM.strip_heredoc,
1. Multiline
Numbered list item
Numbered list item
GFM
 
# nested numbered list
<<-GFM.strip_heredoc,
1. Nested
 
1. Numbered lists
1. Numbered lists
GFM
 
# list item followed by an HR
<<-GFM.strip_heredoc,
- list item
* list item
 
-----
---
GFM
 
'# Heading',
Loading
Loading
@@ -518,11 +516,11 @@ describe 'Copy as GFM', :js do
 
'**Bold**',
 
'_Italics_',
'*Italics*',
 
'~~Strikethrough~~',
 
'-----',
'---',
 
# table
<<-GFM.strip_heredoc,
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment